Repository: phoenixframework/phoenix
Pull request #3316 (2019-03-15 15:08:03)

Update doc for ConnTest.assert_error_sent
Let the reader know where the list of allowed status atoms are. Thanks ✨ 


Patch:
diff --git a/assets/js/phoenix.js b/assets/js/phoenix.js
index 2bc742947..c4aae78b1 100644
--- a/assets/js/phoenix.js
+++ b/assets/js/phoenix.js
@@ -372,50 +372,50 @@ export class Channel {
     this.joinedOnce  = false
     this.joinPush    = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout)
     this.pushBuffer  = []
-    this.rejoinTimer  = new Timer(
-      () => this.rejoinUntilConnected(),
-      this.socket.reconnectAfterMs
-    )
+
+    this.rejoinTimer = new Timer(() => {
+      if(this.socket.isConnected()){ this.rejoin() }
+    }, this.socket.rejoinAfterMs)
+    this.socket.onError(() => this.rejoinTimer.reset())
+    this.socket.onOpen(() => {
+      this.rejoinTimer.reset()
+      if(this.isErrored()){ this.rejoin() }
+    })
     this.joinPush.receive("ok", () => {
       this.state = CHANNEL_STATES.joined
       this.rejoinTimer.reset()
       this.pushBuffer.forEach( pushEvent => pushEvent.send() )
       this.pushBuffer = []
     })
-    this.onClose( () => {
+    this.joinPush.receive("error", () => {
+      this.state = CHANNEL_STATES.errored
+      if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() }
+    })
+    this.onClose(() => {
       this.rejoinTimer.reset()
-      if (this.socket.hasLogger()) this.socket.log("channel", `close ${this.topic} ${this.joinRef()}`)
+      if(this.socket.hasLogger()) this.socket.log("channel", `close ${this.topic} ${this.joinRef()}`)
       this.state = CHANNEL_STATES.closed
       this.socket.remove(this)
     })
-    this.onError( reason => { if(this.isLeaving() || this.isClosed()){ return }
-      if (this.socket.hasLogger()) this.socket.log("channel", `error ${this.topic}`, reason)
+    this.onError(reason => {
+      if(this.socket.hasLogger()) this.socket.log("channel", `error ${this.topic}`, reason)
+      if(this.isJoining()){ this.joinPush.reset() }
       this.state = CHANNEL_STATES.errored
-      this.rejoinTimer.scheduleTimeout()
+      if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() }
     })
-    this.joinPush.receive("timeout", () => { if(!this.isJoining()){ return }
-      if (this.socket.hasLogger()) this.socket.log("channel", `timeout ${this.topic} (${this.joinRef()})`, this.joinPush.timeout)
+    this.joinPush.receive("timeout", () => {
+      if(this.socket.hasLogger()) this.socket.log("channel", `timeout ${this.topic} (${this.joinRef()})`, this.joinPush.timeout)
       let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), this.timeout)
       leavePush.send()
       this.state = CHANNEL_STATES.errored
       this.joinPush.reset()
-      this.rejoinTimer.scheduleTimeout()
+      if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() }
     })
     this.on(CHANNEL_EVENTS.reply, (payload, ref) => {
       this.trigger(this.replyEventName(ref), payload)
     })
   }
 
-  /**
-   * @private
-   */
-  rejoinUntilConnected(){
-    this.rejoinTimer.scheduleTimeout()
-    if(this.socket.isConnected()){
-      this.rejoin()
-    }
-  }
-
   /**
    * Join the channel
    * @param {integer} timeout
@@ -425,8 +425,9 @@ export class Channel {
     if(this.joinedOnce){
       throw new Error(`tried to join multiple times. 'join' can only be called a single time per channel instance`)
     } else {
+      this.timeout = timeout
       this.joinedOnce = true
-      this.rejoin(timeout)
+      this.rejoin()
       return this.joinPush
     }
   }
@@ -523,6 +524,7 @@ export class Channel {
    * @returns {Push}
    */
   leave(timeout = this.timeout){
+    this.rejoinTimer.reset()
     this.state = CHANNEL_STATES.leaving
     let onClose = () => {
       if (this.socket.hasLogger()) this.socket.log("channel", `leave ${this.topic}`)
@@ -683,16 +685,28 @@ const Serializer = {
  *
  * Defaults `DEFAULT_TIMEOUT`
  * @param {number} [opts.heartbeatIntervalMs] - The millisec interval to send a heartbeat message
- * @param {number} [opts.reconnectAfterMs] - The optional function that returns the millsec reconnect interval.
+ * @param {number} [opts.reconnectAfterMs] - The optional function that returns the millsec
+ * socket reconnect interval.
  *
  * Defaults to stepped backoff of:
  *
  * ```javascript
  * function(tries){
- *   return [1000, 5000, 10000][tries - 1] || 10000
+ *   return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000
  * }
- * ```
+ * ````
+ *
+ * @param {number} [opts.rejoinAfterMs] - The optional function that returns the millsec
+ * rejoin interval for individual channels.
+ *
+ * ```javascript
+ * function(tries){
+ *   return [1000, 2000, 5000][tries - 1] || 10000
+ * }
+ * ````
+ *
  * @param {Function} [opts.logger] - The optional function for specialized logging, ie:
+ *
  * ```javascript
  * function(kind, msg, data) {
  *   console.log(`${kind}: ${msg}`, data)
@@ -704,7 +718,9 @@ const Serializer = {
  * Defaults to 20s (double the server long poll timer).
  *
  * @param {{Object|function)} [opts.params] - The optional params to pass when connecting
+ * @param {string} [opts.binaryType] - The binary type to use for binary WebSocket frames.
  *
+ * Defaults to "arraybuffer"
  *
 */
 export class Socket {
@@ -717,6 +733,9 @@ export class Socket {
     this.transport            = opts.transport || global.WebSocket || LongPoll
     this.defaultEncoder       = Serializer.encode
     this.defaultDecoder       = Serializer.decode
+    this.closeWasClean        = false
+    this.unloaded             = false
+    this.binaryType           = opts.binaryType || "arraybuffer"
     if(this.transport !== LongPoll){
       this.encode = opts.encode || this.defaultEncoder
       this.decode = opts.decode || this.defaultDecoder
@@ -724,9 +743,27 @@ export class Socket {
       this.encode = this.defaultEncoder
       this.decode = this.defaultDecoder
     }
-    this.heartbeatIntervalMs  = opts.heartbeatIntervalMs || 30000
-    this.reconnectAfterMs     = opts.reconnectAfterMs || function(tries){
-      return [1000, 2000, 5000, 10000][tries - 1] || 10000
+    if(globalWindow){
+      globalWindow.addEventListener("beforeunload", e => {
+        this.unloaded = true
+        this.abnormalClose("unloaded")
+      })
+    }
+    this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000
+    this.rejoinAfterMs = (tries) => {
+      if(opts.rejoinAfterMs){
+        return opts.rejoinAfterMs(tries)
+      } else {
+        return [1000, 2000, 5000][tries - 1] || 10000
+      }
+    }
+    this.reconnectAfterMs = (tries) => {
+      if(this.unloaded){ return 100 }
+      if(opts.reconnectAfterMs){
+        return opts.reconnectAfterMs(tries)
+      } else {
+        return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000
+      }
     }
     this.logger               = opts.logger || null
     this.longpollerTimeout    = opts.longpollerTimeout || 20000
@@ -766,6 +803,7 @@ export class Socket {
    * @param {string} reason
    */
   disconnect(callback, code, reason){
+    this.closeWasClean = true
     this.reconnectTimer.reset()
     this.teardown(callback, code, reason)
   }
@@ -785,11 +823,12 @@ export class Socket {
     if(this.conn){ return }
 
     this.conn = new this.transport(this.endPointURL())
-    this.conn.timeout   = this.longpollerTimeout
-    this.conn.onopen    = () => this.onConnOpen()
-    this.conn.onerror   = error => this.onConnError(error)
-    this.conn.onmessage = event => this.onConnMessage(event)
-    this.conn.onclose   = event => this.onConnClose(event)
+    this.conn.binaryType = this.binaryType
+    this.conn.timeout    = this.longpollerTimeout
+    this.conn.onopen     = () => this.onConnOpen()
+    this.conn.onerror    = error => this.onConnError(error)
+    this.conn.onmessage  = event => this.onConnMessage(event)
+    this.conn.onclose    = event => this.onConnClose(event)
   }
 
   /**
@@ -840,6 +879,8 @@ export class Socket {
    */
   onConnOpen(){
     if (this.hasLogger()) this.log("transport", `connected to ${this.endPointURL()}`)
+    this.unloaded = false
+    this.closeWasClean = false
     this.flushSendBuffer()
     this.reconnectTimer.reset()
     this.resetHeartbeat()
@@ -869,7 +910,7 @@ export class Socket {
     if (this.hasLogger()) this.log("transport", "close", event)
     this.triggerChanError()
     clearInterval(this.heartbeatTimer)
-    if(event && event.code !== WS_CLOSE_NORMAL) {
+    if(!this.closeWasClean){
       this.reconnectTimer.scheduleTimeout()
     }
     this.stateChangeCallbacks.close.forEach( callback => callback(event) )
@@ -888,7 +929,11 @@ export class Socket {
    * @private
    */
   triggerChanError(){
-    this.channels.forEach( channel => channel.trigger(CHANNEL_EVENTS.error) )
+    this.channels.forEach( channel => {
+      if(!(channel.isErrored() || channel.isLeaving() || channel.isClosed())){
+        channel.trigger(CHANNEL_EVENTS.error)
+      }
+    })
   }
 
   /**
@@ -959,13 +1004,18 @@ export class Socket {
     if(this.pendingHeartbeatRef){
       this.pendingHeartbeatRef = null
       if (this.hasLogger()) this.log("transport", "heartbeat timeout. Attempting to re-establish connection")
-      this.conn.close(WS_CLOSE_NORMAL, "hearbeat timeout")
+      this.abnormalClose("heartbeat timeout")
       return
     }
     this.pendingHeartbeatRef = this.makeRef()
     this.push({topic: "phoenix", event: "heartbeat", payload: {}, ref: this.pendingHeartbeatRef})
   }
 
+  abnormalClose(reason){
+    this.closeWasClean = false
+    this.conn.close(WS_CLOSE_NORMAL, reason)
+  }
+
   flushSendBuffer(){
     if(this.isConnected() && this.sendBuffer.length > 0){
       this.sendBuffer.forEach( callback => callback() )
diff --git a/guides/channels.md b/guides/channels.md
index e0dfe06d4..7987f8d40 100644
--- a/guides/channels.md
+++ b/guides/channels.md
@@ -236,7 +236,7 @@ We can use that library to connect to our socket and join our channel, we just n
 
 ```javascript
 // assets/js/socket.js
-...
+// ...
 socket.connect()
 
 // Now that you are connected, you can join channels with a topic:
@@ -251,7 +251,7 @@ export default socket
 After that, we need to make sure `assets/js/socket.js` gets imported into our application JavaScript file. To do that, uncomment the last line in `assets/js/app.js`.
 
 ```javascript
-...
+// ...
 import socket from "./socket"
 ```
 
@@ -267,7 +267,7 @@ In `lib/hello_web/templates/page/index.html.eex`, we'll replace the existing cod
 Now let's add a couple of event listeners to `assets/js/socket.js`:
 
 ```javascript
-...
+// ...
 let channel           = socket.channel("room:lobby", {})
 let chatInput         = document.querySelector("#chat-input")
 let messagesContainer = document.querySelector("#messages")
@@ -289,7 +289,7 @@ export default socket
 All we had to do is detect that enter was pressed and then `push` an event over the channel with the message body. We named the event `"new_msg"`. With this in place, let's handle the other piece of a chat application where we listen for new messages and append them to our messages container.
 
 ```javascript
-...
+// ...
 let channel           = socket.channel("room:lobby", {})
 let chatInput         = document.querySelector("#chat-input")
 let messagesContainer = document.querySelector("#messages")
diff --git a/installer/templates/phx_assets/phoenix.js b/installer/templates/phx_assets/phoenix.js
index d26df0cc2..e93f5142b 100644
--- a/installer/templates/phx_assets/phoenix.js
+++ b/installer/templates/phx_assets/phoenix.js
@@ -1,1 +1,1 @@
-!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Phoenix=t():e.Phoenix=t()}(this,function(){return function(e){var t={};function n(i){if(t[i])return t[i].exports;var o=t[i]={i:i,l:!1,exports:{}};return e[i].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(i,o,function(t){return e[t]}.bind(null,o));return i},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){(function(t){e.exports=t.Phoenix=n(2)}).call(this,n(1))},function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";function i(e){return function(e){if(Array.isArray(e)){for(var t=0,n=new Array(e.length);t<e.length;t++)n[t]=e[t];return n}}(e)||function(e){if(Symbol.iterator in Object(e)||"[object Arguments]"===Object.prototype.toString.call(e))return Array.from(e)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance")}()}function o(e){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function r(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=[],i=!0,o=!1,r=void 0;try{for(var s,a=e[Symbol.iterator]();!(i=(s=a.next()).done)&&(n.push(s.value),!t||n.length!==t);i=!0);}catch(e){o=!0,r=e}finally{try{i||null==a.return||a.return()}finally{if(o)throw r}}return n}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function a(e,t){for(var n=0;n<t.length;n++){var i=t[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}function c(e,t,n){return t&&a(e.prototype,t),n&&a(e,n),e}n.r(t),n.d(t,"Channel",function(){return b}),n.d(t,"Socket",function(){return R}),n.d(t,"LongPoll",function(){return w}),n.d(t,"Ajax",function(){return C}),n.d(t,"Presence",function(){return S});var u="undefined"!=typeof self?self:null,h="undefined"!=typeof window?window:null,l=u||h||void 0,f={connecting:0,open:1,closing:2,closed:3},p=1e4,d={closed:"closed",errored:"errored",joined:"joined",joining:"joining",leaving:"leaving"},v={close:"phx_close",error:"phx_error",join:"phx_join",reply:"phx_reply",leave:"phx_leave"},y=[v.close,v.error,v.join,v.reply,v.leave],g={longpoll:"longpoll",websocket:"websocket"},m=function(e){if("function"==typeof e)return e;return function(){return e}},k=function(){function e(t,n,i,o){s(this,e),this.channel=t,this.event=n,this.payload=i||function(){return{}},this.receivedResp=null,this.timeout=o,this.timeoutTimer=null,this.recHooks=[],this.sent=!1}return c(e,[{key:"resend",value:function(e){this.timeout=e,this.reset(),this.send()}},{key:"send",value:function(){this.hasReceived("timeout")||(this.startTimeout(),this.sent=!0,this.channel.socket.push({topic:this.channel.topic,event:this.event,payload:this.payload(),ref:this.ref,join_ref:this.channel.joinRef()}))}},{key:"receive",value:function(e,t){return this.hasReceived(e)&&t(this.receivedResp.response),this.recHooks.push({status:e,callback:t}),this}},{key:"reset",value:function(){this.cancelRefEvent(),this.ref=null,this.refEvent=null,this.receivedResp=null,this.sent=!1}},{key:"matchReceive",value:function(e){var t=e.status,n=e.response;e.ref;this.recHooks.filter(function(e){return e.status===t}).forEach(function(e){return e.callback(n)})}},{key:"cancelRefEvent",value:function(){this.refEvent&&this.channel.off(this.refEvent)}},{key:"cancelTimeout",value:function(){clearTimeout(this.timeoutTimer),this.timeoutTimer=null}},{key:"startTimeout",value:function(){var e=this;this.timeoutTimer&&this.cancelTimeout(),this.ref=this.channel.socket.makeRef(),this.refEvent=this.channel.replyEventName(this.ref),this.channel.on(this.refEvent,function(t){e.cancelRefEvent(),e.cancelTimeout(),e.receivedResp=t,e.matchReceive(t)}),this.timeoutTimer=setTimeout(function(){e.trigger("timeout",{})},this.timeout)}},{key:"hasReceived",value:function(e){return this.receivedResp&&this.receivedResp.status===e}},{key:"trigger",value:function(e,t){this.channel.trigger(this.refEvent,{status:e,response:t})}}]),e}(),b=function(){function e(t,n,i){var o=this;s(this,e),this.state=d.closed,this.topic=t,this.params=m(n||{}),this.socket=i,this.bindings=[],this.bindingRef=0,this.timeout=this.socket.timeout,this.joinedOnce=!1,this.joinPush=new k(this,v.join,this.params,this.timeout),this.pushBuffer=[],this.rejoinTimer=new T(function(){return o.rejoinUntilConnected()},this.socket.reconnectAfterMs),this.joinPush.receive("ok",function(){o.state=d.joined,o.rejoinTimer.reset(),o.pushBuffer.forEach(function(e){return e.send()}),o.pushBuffer=[]}),this.onClose(function(){o.rejoinTimer.reset(),o.socket.hasLogger()&&o.socket.log("channel","close ".concat(o.topic," ").concat(o.joinRef())),o.state=d.closed,o.socket.remove(o)}),this.onError(function(e){o.isLeaving()||o.isClosed()||(o.socket.hasLogger()&&o.socket.log("channel","error ".concat(o.topic),e),o.state=d.errored,o.rejoinTimer.scheduleTimeout())}),this.joinPush.receive("timeout",function(){o.isJoining()&&(o.socket.hasLogger()&&o.socket.log("channel","timeout ".concat(o.topic," (").concat(o.joinRef(),")"),o.joinPush.timeout),new k(o,v.leave,m({}),o.timeout).send(),o.state=d.errored,o.joinPush.reset(),o.rejoinTimer.scheduleTimeout())}),this.on(v.reply,function(e,t){o.trigger(o.replyEventName(t),e)})}return c(e,[{key:"rejoinUntilConnected",value:function(){this.rejoinTimer.scheduleTimeout(),this.socket.isConnected()&&this.rejoin()}},{key:"join",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;if(this.joinedOnce)throw new Error("tried to join multiple times. 'join' can only be called a single time per channel instance");return this.joinedOnce=!0,this.rejoin(e),this.joinPush}},{key:"onClose",value:function(e){this.on(v.close,e)}},{key:"onError",value:function(e){return this.on(v.error,function(t){return e(t)})}},{key:"on",value:function(e,t){var n=this.bindingRef++;return this.bindings.push({event:e,ref:n,callback:t}),n}},{key:"off",value:function(e,t){this.bindings=this.bindings.filter(function(n){return!(n.event===e&&(void 0===t||t===n.ref))})}},{key:"canPush",value:function(){return this.socket.isConnected()&&this.isJoined()}},{key:"push",value:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.timeout;if(!this.joinedOnce)throw new Error("tried to push '".concat(e,"' to '").concat(this.topic,"' before joining. Use channel.join() before pushing events"));var i=new k(this,e,function(){return t},n);return this.canPush()?i.send():(i.startTimeout(),this.pushBuffer.push(i)),i}},{key:"leave",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.state=d.leaving;var n=function(){e.socket.hasLogger()&&e.socket.log("channel","leave ".concat(e.topic)),e.trigger(v.close,"leave")},i=new k(this,v.leave,m({}),t);return i.receive("ok",function(){return n()}).receive("timeout",function(){return n()}),i.send(),this.canPush()||i.trigger("ok",{}),i}},{key:"onMessage",value:function(e,t,n){return t}},{key:"isLifecycleEvent",value:function(e){return y.indexOf(e)>=0}},{key:"isMember",value:function(e,t,n,i){return this.topic===e&&(!i||i===this.joinRef()||!this.isLifecycleEvent(t)||(this.socket.hasLogger()&&this.socket.log("channel","dropping outdated message",{topic:e,event:t,payload:n,joinRef:i}),!1))}},{key:"joinRef",value:function(){return this.joinPush.ref}},{key:"sendJoin",value:function(e){this.state=d.joining,this.joinPush.resend(e)}},{key:"rejoin",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.isLeaving()||this.sendJoin(e)}},{key:"trigger",value:function(e,t,n,i){var o=this.onMessage(e,t,n,i);if(t&&!o)throw new Error("channel onMessage callbacks must return the payload, modified or unmodified");for(var r=0;r<this.bindings.length;r++){var s=this.bindings[r];s.event===e&&s.callback(o,n,i||this.joinRef())}}},{key:"replyEventName",value:function(e){return"chan_reply_".concat(e)}},{key:"isClosed",value:function(){return this.state===d.closed}},{key:"isErrored",value:function(){return this.state===d.errored}},{key:"isJoined",value:function(){return this.state===d.joined}},{key:"isJoining",value:function(){return this.state===d.joining}},{key:"isLeaving",value:function(){return this.state===d.leaving}}]),e}(),j={encode:function(e,t){var n=[e.join_ref,e.ref,e.topic,e.event,e.payload];return t(JSON.stringify(n))},decode:function(e,t){var n=r(JSON.parse(e),5);return t({join_ref:n[0],ref:n[1],topic:n[2],event:n[3],payload:n[4]})}},R=function(){function e(t){var n=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};s(this,e),this.stateChangeCallbacks={open:[],close:[],error:[],message:[]},this.channels=[],this.sendBuffer=[],this.ref=0,this.timeout=i.timeout||p,this.transport=i.transport||l.WebSocket||w,this.defaultEncoder=j.encode,this.defaultDecoder=j.decode,this.transport!==w?(this.encode=i.encode||this.defaultEncoder,this.decode=i.decode||this.defaultDecoder):(this.encode=this.defaultEncoder,this.decode=this.defaultDecoder),this.heartbeatIntervalMs=i.heartbeatIntervalMs||3e4,this.reconnectAfterMs=i.reconnectAfterMs||function(e){return[1e3,2e3,5e3,1e4][e-1]||1e4},this.logger=i.logger||null,this.longpollerTimeout=i.longpollerTimeout||2e4,this.params=m(i.params||{}),this.endPoint="".concat(t,"/").concat(g.websocket),this.heartbeatTimer=null,this.pendingHeartbeatRef=null,this.reconnectTimer=new T(function(){n.teardown(function(){return n.connect()})},this.reconnectAfterMs)}return c(e,[{key:"protocol",value:function(){return location.protocol.match(/^https/)?"wss":"ws"}},{key:"endPointURL",value:function(){var e=C.appendParams(C.appendParams(this.endPoint,this.params()),{vsn:"2.0.0"});return"/"!==e.charAt(0)?e:"/"===e.charAt(1)?"".concat(this.protocol(),":").concat(e):"".concat(this.protocol(),"://").concat(location.host).concat(e)}},{key:"disconnect",value:function(e,t,n){this.reconnectTimer.reset(),this.teardown(e,t,n)}},{key:"connect",value:function(e){var t=this;e&&(console&&console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"),this.params=m(e)),this.conn||(this.conn=new this.transport(this.endPointURL()),this.conn.timeout=this.longpollerTimeout,this.conn.onopen=function(){return t.onConnOpen()},this.conn.onerror=function(e){return t.onConnError(e)},this.conn.onmessage=function(e){return t.onConnMessage(e)},this.conn.onclose=function(e){return t.onConnClose(e)})}},{key:"log",value:function(e,t,n){this.logger(e,t,n)}},{key:"hasLogger",value:function(){return null!==this.logger}},{key:"onOpen",value:function(e){this.stateChangeCallbacks.open.push(e)}},{key:"onClose",value:function(e){this.stateChangeCallbacks.close.push(e)}},{key:"onError",value:function(e){this.stateChangeCallbacks.error.push(e)}},{key:"onMessage",value:function(e){this.stateChangeCallbacks.message.push(e)}},{key:"onConnOpen",value:function(){this.hasLogger()&&this.log("transport","connected to ".concat(this.endPointURL())),this.flushSendBuffer(),this.reconnectTimer.reset(),this.resetHeartbeat(),this.stateChangeCallbacks.open.forEach(function(e){return e()})}},{key:"resetHeartbeat",value:function(){var e=this;this.conn.skipHeartbeat||(this.pendingHeartbeatRef=null,clearInterval(this.heartbeatTimer),this.heartbeatTimer=setInterval(function(){return e.sendHeartbeat()},this.heartbeatIntervalMs))}},{key:"teardown",value:function(e,t,n){this.conn&&(this.conn.onclose=function(){},t?this.conn.close(t,n||""):this.conn.close(),this.conn=null),e&&e()}},{key:"onConnClose",value:function(e){this.hasLogger()&&this.log("transport","close",e),this.triggerChanError(),clearInterval(this.heartbeatTimer),e&&1e3!==e.code&&this.reconnectTimer.scheduleTimeout(),this.stateChangeCallbacks.close.forEach(function(t){return t(e)})}},{key:"onConnError",value:function(e){this.hasLogger()&&this.log("transport",e),this.triggerChanError(),this.stateChangeCallbacks.error.forEach(function(t){return t(e)})}},{key:"triggerChanError",value:function(){this.channels.forEach(function(e){return e.trigger(v.error)})}},{key:"connectionState",value:function(){switch(this.conn&&this.conn.readyState){case f.connecting:return"connecting";case f.open:return"open";case f.closing:return"closing";default:return"closed"}}},{key:"isConnected",value:function(){return"open"===this.connectionState()}},{key:"remove",value:function(e){this.channels=this.channels.filter(function(t){return t.joinRef()!==e.joinRef()})}},{key:"channel",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=new b(e,t,this);return this.channels.push(n),n}},{key:"push",value:function(e){var t=this;if(this.hasLogger()){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;this.log("push","".concat(n," ").concat(i," (").concat(s,", ").concat(r,")"),o)}this.isConnected()?this.encode(e,function(e){return t.conn.send(e)}):this.sendBuffer.push(function(){return t.encode(e,function(e){return t.conn.send(e)})})}},{key:"makeRef",value:function(){var e=this.ref+1;return e===this.ref?this.ref=0:this.ref=e,this.ref.toString()}},{key:"sendHeartbeat",value:function(){if(this.isConnected()){if(this.pendingHeartbeatRef)return this.pendingHeartbeatRef=null,this.hasLogger()&&this.log("transport","heartbeat timeout. Attempting to re-establish connection"),void this.conn.close(1e3,"hearbeat timeout");this.pendingHeartbeatRef=this.makeRef(),this.push({topic:"phoenix",event:"heartbeat",payload:{},ref:this.pendingHeartbeatRef})}}},{key:"flushSendBuffer",value:function(){this.isConnected()&&this.sendBuffer.length>0&&(this.sendBuffer.forEach(function(e){return e()}),this.sendBuffer=[])}},{key:"onConnMessage",value:function(e){var t=this;this.decode(e.data,function(e){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;r&&r===t.pendingHeartbeatRef&&(t.pendingHeartbeatRef=null),t.hasLogger()&&t.log("receive","".concat(o.status||""," ").concat(n," ").concat(i," ").concat(r&&"("+r+")"||""),o);for(var a=0;a<t.channels.length;a++){var c=t.channels[a];c.isMember(n,i,o,s)&&c.trigger(i,o,r,s)}for(var u=0;u<t.stateChangeCallbacks.message.length;u++)t.stateChangeCallbacks.message[u](e)})}}]),e}(),w=function(){function e(t){s(this,e),this.endPoint=null,this.token=null,this.skipHeartbeat=!0,this.onopen=function(){},this.onerror=function(){},this.onmessage=function(){},this.onclose=function(){},this.pollEndpoint=this.normalizeEndpoint(t),this.readyState=f.connecting,this.poll()}return c(e,[{key:"normalizeEndpoint",value:function(e){return e.replace("ws://","http://").replace("wss://","https://").replace(new RegExp("(.*)/"+g.websocket),"$1/"+g.longpoll)}},{key:"endpointURL",value:function(){return C.appendParams(this.pollEndpoint,{token:this.token})}},{key:"closeAndRetry",value:function(){this.close(),this.readyState=f.connecting}},{key:"ontimeout",value:function(){this.onerror("timeout"),this.closeAndRetry()}},{key:"poll",value:function(){var e=this;this.readyState!==f.open&&this.readyState!==f.connecting||C.request("GET",this.endpointURL(),"application/json",null,this.timeout,this.ontimeout.bind(this),function(t){if(t){var n=t.status,i=t.token,o=t.messages;e.token=i}else n=0;switch(n){case 200:o.forEach(function(t){return e.onmessage({data:t})}),e.poll();break;case 204:e.poll();break;case 410:e.readyState=f.open,e.onopen(),e.poll();break;case 0:case 500:e.onerror(),e.closeAndRetry();break;default:throw new Error("unhandled poll status ".concat(n))}})}},{key:"send",value:function(e){var t=this;C.request("POST",this.endpointURL(),"application/json",e,this.timeout,this.onerror.bind(this,"timeout"),function(e){e&&200===e.status||(t.onerror(e&&e.status),t.closeAndRetry())})}},{key:"close",value:function(e,t){this.readyState=f.closed,this.onclose()}}]),e}(),C=function(){function e(){s(this,e)}return c(e,null,[{key:"request",value:function(e,t,n,i,o,r,s){if(l.XDomainRequest){var a=new XDomainRequest;this.xdomainRequest(a,e,t,i,o,r,s)}else{var c=l.XMLHttpRequest?new l.XMLHttpRequest:new ActiveXObject("Microsoft.XMLHTTP");this.xhrRequest(c,e,t,n,i,o,r,s)}}},{key:"xdomainRequest",value:function(e,t,n,i,o,r,s){var a=this;e.timeout=o,e.open(t,n),e.onload=function(){var t=a.parseJSON(e.responseText);s&&s(t)},r&&(e.ontimeout=r),e.onprogress=function(){},e.send(i)}},{key:"xhrRequest",value:function(e,t,n,i,o,r,s,a){var c=this;e.open(t,n,!0),e.timeout=r,e.setRequestHeader("Content-Type",i),e.onerror=function(){a&&a(null)},e.onreadystatechange=function(){if(e.readyState===c.states.complete&&a){var t=c.parseJSON(e.responseText);a(t)}},s&&(e.ontimeout=s),e.send(o)}},{key:"parseJSON",value:function(e){if(!e||""===e)return null;try{return JSON.parse(e)}catch(t){return console&&console.log("failed to parse JSON response",e),null}}},{key:"serialize",value:function(e,t){var n=[];for(var i in e)if(e.hasOwnProperty(i)){var r=t?"".concat(t,"[").concat(i,"]"):i,s=e[i];"object"===o(s)?n.push(this.serialize(s,r)):n.push(encodeURIComponent(r)+"="+encodeURIComponent(s))}return n.join("&")}},{key:"appendParams",value:function(e,t){if(0===Object.keys(t).length)return e;var n=e.match(/\?/)?"&":"?";return"".concat(e).concat(n).concat(this.serialize(t))}}]),e}();C.states={complete:4};var S=function(){function e(t){var n=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};s(this,e);var o=i.events||{state:"presence_state",diff:"presence_diff"};this.state={},this.pendingDiffs=[],this.channel=t,this.joinRef=null,this.caller={onJoin:function(){},onLeave:function(){},onSync:function(){}},this.channel.on(o.state,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.joinRef=n.channel.joinRef(),n.state=e.syncState(n.state,t,o,r),n.pendingDiffs.forEach(function(t){n.state=e.syncDiff(n.state,t,o,r)}),n.pendingDiffs=[],s()}),this.channel.on(o.diff,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.inPendingSyncState()?n.pendingDiffs.push(t):(n.state=e.syncDiff(n.state,t,o,r),s())})}return c(e,[{key:"onJoin",value:function(e){this.caller.onJoin=e}},{key:"onLeave",value:function(e){this.caller.onLeave=e}},{key:"onSync",value:function(e){this.caller.onSync=e}},{key:"list",value:function(t){return e.list(this.state,t)}},{key:"inPendingSyncState",value:function(){return!this.joinRef||this.joinRef!==this.channel.joinRef()}}],[{key:"syncState",value:function(e,t,n,i){var o=this,r=this.clone(e),s={},a={};return this.map(r,function(e,n){t[e]||(a[e]=n)}),this.map(t,function(e,t){var n=r[e];if(n){var i=t.metas.map(function(e){return e.phx_ref}),c=n.metas.map(function(e){return e.phx_ref}),u=t.metas.filter(function(e){return c.indexOf(e.phx_ref)<0}),h=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0});u.length>0&&(s[e]=t,s[e].metas=u),h.length>0&&(a[e]=o.clone(n),a[e].metas=h)}else s[e]=t}),this.syncDiff(r,{joins:s,leaves:a},n,i)}},{key:"syncDiff",value:function(e,t,n,o){var r=t.joins,s=t.leaves,a=this.clone(e);return n||(n=function(){}),o||(o=function(){}),this.map(r,function(e,t){var o=a[e];if(a[e]=t,o){var r,s=a[e].metas.map(function(e){return e.phx_ref}),c=o.metas.filter(function(e){return s.indexOf(e.phx_ref)<0});(r=a[e].metas).unshift.apply(r,i(c))}n(e,o,t)}),this.map(s,function(e,t){var n=a[e];if(n){var i=t.metas.map(function(e){return e.phx_ref});n.metas=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0}),o(e,n,t),0===n.metas.length&&delete a[e]}}),a}},{key:"list",value:function(e,t){return t||(t=function(e,t){return t}),this.map(e,function(e,n){return t(e,n)})}},{key:"map",value:function(e,t){return Object.getOwnPropertyNames(e).map(function(n){return t(n,e[n])})}},{key:"clone",value:function(e){return JSON.parse(JSON.stringify(e))}}]),e}(),T=function(){function e(t,n){s(this,e),this.callback=t,this.timerCalc=n,this.timer=null,this.tries=0}return c(e,[{key:"reset",value:function(){this.tries=0,clearTimeout(this.timer)}},{key:"scheduleTimeout",value:function(){var e=this;clearTimeout(this.timer),this.timer=setTimeout(function(){e.tries=e.tries+1,e.callback()},this.timerCalc(this.tries+1))}}]),e}()}])});
\ No newline at end of file
+!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Phoenix=t():e.Phoenix=t()}(this,function(){return function(e){var t={};function n(i){if(t[i])return t[i].exports;var o=t[i]={i:i,l:!1,exports:{}};return e[i].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(i,o,function(t){return e[t]}.bind(null,o));return i},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){(function(t){e.exports=t.Phoenix=n(2)}).call(this,n(1))},function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";function i(e){return function(e){if(Array.isArray(e)){for(var t=0,n=new Array(e.length);t<e.length;t++)n[t]=e[t];return n}}(e)||function(e){if(Symbol.iterator in Object(e)||"[object Arguments]"===Object.prototype.toString.call(e))return Array.from(e)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance")}()}function o(e){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function r(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=[],i=!0,o=!1,r=void 0;try{for(var s,a=e[Symbol.iterator]();!(i=(s=a.next()).done)&&(n.push(s.value),!t||n.length!==t);i=!0);}catch(e){o=!0,r=e}finally{try{i||null==a.return||a.return()}finally{if(o)throw r}}return n}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function a(e,t){for(var n=0;n<t.length;n++){var i=t[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}function c(e,t,n){return t&&a(e.prototype,t),n&&a(e,n),e}n.r(t),n.d(t,"Channel",function(){return b}),n.d(t,"Socket",function(){return R}),n.d(t,"LongPoll",function(){return C}),n.d(t,"Ajax",function(){return T}),n.d(t,"Presence",function(){return w});var u="undefined"!=typeof self?self:null,h="undefined"!=typeof window?window:null,l=u||h||void 0,f={connecting:0,open:1,closing:2,closed:3},d=1e4,p={closed:"closed",errored:"errored",joined:"joined",joining:"joining",leaving:"leaving"},v={close:"phx_close",error:"phx_error",join:"phx_join",reply:"phx_reply",leave:"phx_leave"},y=[v.close,v.error,v.join,v.reply,v.leave],m={longpoll:"longpoll",websocket:"websocket"},g=function(e){if("function"==typeof e)return e;return function(){return e}},k=function(){function e(t,n,i,o){s(this,e),this.channel=t,this.event=n,this.payload=i||function(){return{}},this.receivedResp=null,this.timeout=o,this.timeoutTimer=null,this.recHooks=[],this.sent=!1}return c(e,[{key:"resend",value:function(e){this.timeout=e,this.reset(),this.send()}},{key:"send",value:function(){this.hasReceived("timeout")||(this.startTimeout(),this.sent=!0,this.channel.socket.push({topic:this.channel.topic,event:this.event,payload:this.payload(),ref:this.ref,join_ref:this.channel.joinRef()}))}},{key:"receive",value:function(e,t){return this.hasReceived(e)&&t(this.receivedResp.response),this.recHooks.push({status:e,callback:t}),this}},{key:"reset",value:function(){this.cancelRefEvent(),this.ref=null,this.refEvent=null,this.receivedResp=null,this.sent=!1}},{key:"matchReceive",value:function(e){var t=e.status,n=e.response;e.ref;this.recHooks.filter(function(e){return e.status===t}).forEach(function(e){return e.callback(n)})}},{key:"cancelRefEvent",value:function(){this.refEvent&&this.channel.off(this.refEvent)}},{key:"cancelTimeout",value:function(){clearTimeout(this.timeoutTimer),this.timeoutTimer=null}},{key:"startTimeout",value:function(){var e=this;this.timeoutTimer&&this.cancelTimeout(),this.ref=this.channel.socket.makeRef(),this.refEvent=this.channel.replyEventName(this.ref),this.channel.on(this.refEvent,function(t){e.cancelRefEvent(),e.cancelTimeout(),e.receivedResp=t,e.matchReceive(t)}),this.timeoutTimer=setTimeout(function(){e.trigger("timeout",{})},this.timeout)}},{key:"hasReceived",value:function(e){return this.receivedResp&&this.receivedResp.status===e}},{key:"trigger",value:function(e,t){this.channel.trigger(this.refEvent,{status:e,response:t})}}]),e}(),b=function(){function e(t,n,i){var o=this;s(this,e),this.state=p.closed,this.topic=t,this.params=g(n||{}),this.socket=i,this.bindings=[],this.bindingRef=0,this.timeout=this.socket.timeout,this.joinedOnce=!1,this.joinPush=new k(this,v.join,this.params,this.timeout),this.pushBuffer=[],this.rejoinTimer=new E(function(){o.socket.isConnected()&&o.rejoin()},this.socket.rejoinAfterMs),this.socket.onError(function(){return o.rejoinTimer.reset()}),this.socket.onOpen(function(){o.rejoinTimer.reset(),o.isErrored()&&o.rejoin()}),this.joinPush.receive("ok",function(){o.state=p.joined,o.rejoinTimer.reset(),o.pushBuffer.forEach(function(e){return e.send()}),o.pushBuffer=[]}),this.joinPush.receive("error",function(){o.state=p.errored,o.socket.isConnected()&&o.rejoinTimer.scheduleTimeout()}),this.onClose(function(){o.rejoinTimer.reset(),o.socket.hasLogger()&&o.socket.log("channel","close ".concat(o.topic," ").concat(o.joinRef())),o.state=p.closed,o.socket.remove(o)}),this.onError(function(e){o.socket.hasLogger()&&o.socket.log("channel","error ".concat(o.topic),e),o.isJoining()&&o.joinPush.reset(),o.state=p.errored,o.socket.isConnected()&&o.rejoinTimer.scheduleTimeout()}),this.joinPush.receive("timeout",function(){o.socket.hasLogger()&&o.socket.log("channel","timeout ".concat(o.topic," (").concat(o.joinRef(),")"),o.joinPush.timeout),new k(o,v.leave,g({}),o.timeout).send(),o.state=p.errored,o.joinPush.reset(),o.socket.isConnected()&&o.rejoinTimer.scheduleTimeout()}),this.on(v.reply,function(e,t){o.trigger(o.replyEventName(t),e)})}return c(e,[{key:"join",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;if(this.joinedOnce)throw new Error("tried to join multiple times. 'join' can only be called a single time per channel instance");return this.timeout=e,this.joinedOnce=!0,this.rejoin(),this.joinPush}},{key:"onClose",value:function(e){this.on(v.close,e)}},{key:"onError",value:function(e){return this.on(v.error,function(t){return e(t)})}},{key:"on",value:function(e,t){var n=this.bindingRef++;return this.bindings.push({event:e,ref:n,callback:t}),n}},{key:"off",value:function(e,t){this.bindings=this.bindings.filter(function(n){return!(n.event===e&&(void 0===t||t===n.ref))})}},{key:"canPush",value:function(){return this.socket.isConnected()&&this.isJoined()}},{key:"push",value:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.timeout;if(!this.joinedOnce)throw new Error("tried to push '".concat(e,"' to '").concat(this.topic,"' before joining. Use channel.join() before pushing events"));var i=new k(this,e,function(){return t},n);return this.canPush()?i.send():(i.startTimeout(),this.pushBuffer.push(i)),i}},{key:"leave",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.rejoinTimer.reset(),this.state=p.leaving;var n=function(){e.socket.hasLogger()&&e.socket.log("channel","leave ".concat(e.topic)),e.trigger(v.close,"leave")},i=new k(this,v.leave,g({}),t);return i.receive("ok",function(){return n()}).receive("timeout",function(){return n()}),i.send(),this.canPush()||i.trigger("ok",{}),i}},{key:"onMessage",value:function(e,t,n){return t}},{key:"isLifecycleEvent",value:function(e){return y.indexOf(e)>=0}},{key:"isMember",value:function(e,t,n,i){return this.topic===e&&(!i||i===this.joinRef()||!this.isLifecycleEvent(t)||(this.socket.hasLogger()&&this.socket.log("channel","dropping outdated message",{topic:e,event:t,payload:n,joinRef:i}),!1))}},{key:"joinRef",value:function(){return this.joinPush.ref}},{key:"sendJoin",value:function(e){this.state=p.joining,this.joinPush.resend(e)}},{key:"rejoin",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.isLeaving()||this.sendJoin(e)}},{key:"trigger",value:function(e,t,n,i){var o=this.onMessage(e,t,n,i);if(t&&!o)throw new Error("channel onMessage callbacks must return the payload, modified or unmodified");for(var r=0;r<this.bindings.length;r++){var s=this.bindings[r];s.event===e&&s.callback(o,n,i||this.joinRef())}}},{key:"replyEventName",value:function(e){return"chan_reply_".concat(e)}},{key:"isClosed",value:function(){return this.state===p.closed}},{key:"isErrored",value:function(){return this.state===p.errored}},{key:"isJoined",value:function(){return this.state===p.joined}},{key:"isJoining",value:function(){return this.state===p.joining}},{key:"isLeaving",value:function(){return this.state===p.leaving}}]),e}(),j={encode:function(e,t){var n=[e.join_ref,e.ref,e.topic,e.event,e.payload];return t(JSON.stringify(n))},decode:function(e,t){var n=r(JSON.parse(e),5);return t({join_ref:n[0],ref:n[1],topic:n[2],event:n[3],payload:n[4]})}},R=function(){function e(t){var n=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};s(this,e),this.stateChangeCallbacks={open:[],close:[],error:[],message:[]},this.channels=[],this.sendBuffer=[],this.ref=0,this.timeout=i.timeout||d,this.transport=i.transport||l.WebSocket||C,this.defaultEncoder=j.encode,this.defaultDecoder=j.decode,this.closeWasClean=!1,this.unloaded=!1,this.binaryType=i.binaryType||"arraybuffer",this.transport!==C?(this.encode=i.encode||this.defaultEncoder,this.decode=i.decode||this.defaultDecoder):(this.encode=this.defaultEncoder,this.decode=this.defaultDecoder),h&&h.addEventListener("beforeunload",function(e){n.unloaded=!0,n.abnormalClose("unloaded")}),this.heartbeatIntervalMs=i.heartbeatIntervalMs||3e4,this.rejoinAfterMs=function(e){return i.rejoinAfterMs?i.rejoinAfterMs(e):[1e3,2e3,5e3][e-1]||1e4},this.reconnectAfterMs=function(e){return n.unloaded?100:i.reconnectAfterMs?i.reconnectAfterMs(e):[10,50,100,150,200,250,500,1e3,2e3][e-1]||5e3},this.logger=i.logger||null,this.longpollerTimeout=i.longpollerTimeout||2e4,this.params=g(i.params||{}),this.endPoint="".concat(t,"/").concat(m.websocket),this.heartbeatTimer=null,this.pendingHeartbeatRef=null,this.reconnectTimer=new E(function(){n.teardown(function(){return n.connect()})},this.reconnectAfterMs)}return c(e,[{key:"protocol",value:function(){return location.protocol.match(/^https/)?"wss":"ws"}},{key:"endPointURL",value:function(){var e=T.appendParams(T.appendParams(this.endPoint,this.params()),{vsn:"2.0.0"});return"/"!==e.charAt(0)?e:"/"===e.charAt(1)?"".concat(this.protocol(),":").concat(e):"".concat(this.protocol(),"://").concat(location.host).concat(e)}},{key:"disconnect",value:function(e,t,n){this.closeWasClean=!0,this.reconnectTimer.reset(),this.teardown(e,t,n)}},{key:"connect",value:function(e){var t=this;e&&(console&&console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"),this.params=g(e)),this.conn||(this.conn=new this.transport(this.endPointURL()),this.conn.binaryType=this.binaryType,this.conn.timeout=this.longpollerTimeout,this.conn.onopen=function(){return t.onConnOpen()},this.conn.onerror=function(e){return t.onConnError(e)},this.conn.onmessage=function(e){return t.onConnMessage(e)},this.conn.onclose=function(e){return t.onConnClose(e)})}},{key:"log",value:function(e,t,n){this.logger(e,t,n)}},{key:"hasLogger",value:function(){return null!==this.logger}},{key:"onOpen",value:function(e){this.stateChangeCallbacks.open.push(e)}},{key:"onClose",value:function(e){this.stateChangeCallbacks.close.push(e)}},{key:"onError",value:function(e){this.stateChangeCallbacks.error.push(e)}},{key:"onMessage",value:function(e){this.stateChangeCallbacks.message.push(e)}},{key:"onConnOpen",value:function(){this.hasLogger()&&this.log("transport","connected to ".concat(this.endPointURL())),this.unloaded=!1,this.closeWasClean=!1,this.flushSendBuffer(),this.reconnectTimer.reset(),this.resetHeartbeat(),this.stateChangeCallbacks.open.forEach(function(e){return e()})}},{key:"resetHeartbeat",value:function(){var e=this;this.conn&&this.conn.skipHeartbeat||(this.pendingHeartbeatRef=null,clearInterval(this.heartbeatTimer),this.heartbeatTimer=setInterval(function(){return e.sendHeartbeat()},this.heartbeatIntervalMs))}},{key:"teardown",value:function(e,t,n){this.conn&&(this.conn.onclose=function(){},t?this.conn.close(t,n||""):this.conn.close(),this.conn=null),e&&e()}},{key:"onConnClose",value:function(e){this.hasLogger()&&this.log("transport","close",e),this.triggerChanError(),clearInterval(this.heartbeatTimer),this.closeWasClean||this.reconnectTimer.scheduleTimeout(),this.stateChangeCallbacks.close.forEach(function(t){return t(e)})}},{key:"onConnError",value:function(e){this.hasLogger()&&this.log("transport",e),this.triggerChanError(),this.stateChangeCallbacks.error.forEach(function(t){return t(e)})}},{key:"triggerChanError",value:function(){this.channels.forEach(function(e){e.isErrored()||e.isLeaving()||e.isClosed()||e.trigger(v.error)})}},{key:"connectionState",value:function(){switch(this.conn&&this.conn.readyState){case f.connecting:return"connecting";case f.open:return"open";case f.closing:return"closing";default:return"closed"}}},{key:"isConnected",value:function(){return"open"===this.connectionState()}},{key:"remove",value:function(e){this.channels=this.channels.filter(function(t){return t.joinRef()!==e.joinRef()})}},{key:"channel",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=new b(e,t,this);return this.channels.push(n),n}},{key:"push",value:function(e){var t=this;if(this.hasLogger()){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;this.log("push","".concat(n," ").concat(i," (").concat(s,", ").concat(r,")"),o)}this.isConnected()?this.encode(e,function(e){return t.conn.send(e)}):this.sendBuffer.push(function(){return t.encode(e,function(e){return t.conn.send(e)})})}},{key:"makeRef",value:function(){var e=this.ref+1;return e===this.ref?this.ref=0:this.ref=e,this.ref.toString()}},{key:"sendHeartbeat",value:function(){if(this.isConnected()){if(this.pendingHeartbeatRef)return this.pendingHeartbeatRef=null,this.hasLogger()&&this.log("transport","heartbeat timeout. Attempting to re-establish connection"),void this.abnormalClose("heartbeat timeout");this.pendingHeartbeatRef=this.makeRef(),this.push({topic:"phoenix",event:"heartbeat",payload:{},ref:this.pendingHeartbeatRef})}}},{key:"abnormalClose",value:function(e){this.closeWasClean=!1,this.conn.close(1e3,e)}},{key:"flushSendBuffer",value:function(){this.isConnected()&&this.sendBuffer.length>0&&(this.sendBuffer.forEach(function(e){return e()}),this.sendBuffer=[])}},{key:"onConnMessage",value:function(e){var t=this;this.decode(e.data,function(e){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;r&&r===t.pendingHeartbeatRef&&(t.pendingHeartbeatRef=null),t.hasLogger()&&t.log("receive","".concat(o.status||""," ").concat(n," ").concat(i," ").concat(r&&"("+r+")"||""),o);for(var a=0;a<t.channels.length;a++){var c=t.channels[a];c.isMember(n,i,o,s)&&c.trigger(i,o,r,s)}for(var u=0;u<t.stateChangeCallbacks.message.length;u++)t.stateChangeCallbacks.message[u](e)})}}]),e}(),C=function(){function e(t){s(this,e),this.endPoint=null,this.token=null,this.skipHeartbeat=!0,this.onopen=function(){},this.onerror=function(){},this.onmessage=function(){},this.onclose=function(){},this.pollEndpoint=this.normalizeEndpoint(t),this.readyState=f.connecting,this.poll()}return c(e,[{key:"normalizeEndpoint",value:function(e){return e.replace("ws://","http://").replace("wss://","https://").replace(new RegExp("(.*)/"+m.websocket),"$1/"+m.longpoll)}},{key:"endpointURL",value:function(){return T.appendParams(this.pollEndpoint,{token:this.token})}},{key:"closeAndRetry",value:function(){this.close(),this.readyState=f.connecting}},{key:"ontimeout",value:function(){this.onerror("timeout"),this.closeAndRetry()}},{key:"poll",value:function(){var e=this;this.readyState!==f.open&&this.readyState!==f.connecting||T.request("GET",this.endpointURL(),"application/json",null,this.timeout,this.ontimeout.bind(this),function(t){if(t){var n=t.status,i=t.token,o=t.messages;e.token=i}else n=0;switch(n){case 200:o.forEach(function(t){return e.onmessage({data:t})}),e.poll();break;case 204:e.poll();break;case 410:e.readyState=f.open,e.onopen(),e.poll();break;case 0:case 500:e.onerror(),e.closeAndRetry();break;default:throw new Error("unhandled poll status ".concat(n))}})}},{key:"send",value:function(e){var t=this;T.request("POST",this.endpointURL(),"application/json",e,this.timeout,this.onerror.bind(this,"timeout"),function(e){e&&200===e.status||(t.onerror(e&&e.status),t.closeAndRetry())})}},{key:"close",value:function(e,t){this.readyState=f.closed,this.onclose()}}]),e}(),T=function(){function e(){s(this,e)}return c(e,null,[{key:"request",value:function(e,t,n,i,o,r,s){if(l.XDomainRequest){var a=new XDomainRequest;this.xdomainRequest(a,e,t,i,o,r,s)}else{var c=l.XMLHttpRequest?new l.XMLHttpRequest:new ActiveXObject("Microsoft.XMLHTTP");this.xhrRequest(c,e,t,n,i,o,r,s)}}},{key:"xdomainRequest",value:function(e,t,n,i,o,r,s){var a=this;e.timeout=o,e.open(t,n),e.onload=function(){var t=a.parseJSON(e.responseText);s&&s(t)},r&&(e.ontimeout=r),e.onprogress=function(){},e.send(i)}},{key:"xhrRequest",value:function(e,t,n,i,o,r,s,a){var c=this;e.open(t,n,!0),e.timeout=r,e.setRequestHeader("Content-Type",i),e.onerror=function(){a&&a(null)},e.onreadystatechange=function(){if(e.readyState===c.states.complete&&a){var t=c.parseJSON(e.responseText);a(t)}},s&&(e.ontimeout=s),e.send(o)}},{key:"parseJSON",value:function(e){if(!e||""===e)return null;try{return JSON.parse(e)}catch(t){return console&&console.log("failed to parse JSON response",e),null}}},{key:"serialize",value:function(e,t){var n=[];for(var i in e)if(e.hasOwnProperty(i)){var r=t?"".concat(t,"[").concat(i,"]"):i,s=e[i];"object"===o(s)?n.push(this.serialize(s,r)):n.push(encodeURIComponent(r)+"="+encodeURIComponent(s))}return n.join("&")}},{key:"appendParams",value:function(e,t){if(0===Object.keys(t).length)return e;var n=e.match(/\?/)?"&":"?";return"".concat(e).concat(n).concat(this.serialize(t))}}]),e}();T.states={complete:4};var w=function(){function e(t){var n=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};s(this,e);var o=i.events||{state:"presence_state",diff:"presence_diff"};this.state={},this.pendingDiffs=[],this.channel=t,this.joinRef=null,this.caller={onJoin:function(){},onLeave:function(){},onSync:function(){}},this.channel.on(o.state,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.joinRef=n.channel.joinRef(),n.state=e.syncState(n.state,t,o,r),n.pendingDiffs.forEach(function(t){n.state=e.syncDiff(n.state,t,o,r)}),n.pendingDiffs=[],s()}),this.channel.on(o.diff,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.inPendingSyncState()?n.pendingDiffs.push(t):(n.state=e.syncDiff(n.state,t,o,r),s())})}return c(e,[{key:"onJoin",value:function(e){this.caller.onJoin=e}},{key:"onLeave",value:function(e){this.caller.onLeave=e}},{key:"onSync",value:function(e){this.caller.onSync=e}},{key:"list",value:function(t){return e.list(this.state,t)}},{key:"inPendingSyncState",value:function(){return!this.joinRef||this.joinRef!==this.channel.joinRef()}}],[{key:"syncState",value:function(e,t,n,i){var o=this,r=this.clone(e),s={},a={};return this.map(r,function(e,n){t[e]||(a[e]=n)}),this.map(t,function(e,t){var n=r[e];if(n){var i=t.metas.map(function(e){return e.phx_ref}),c=n.metas.map(function(e){return e.phx_ref}),u=t.metas.filter(function(e){return c.indexOf(e.phx_ref)<0}),h=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0});u.length>0&&(s[e]=t,s[e].metas=u),h.length>0&&(a[e]=o.clone(n),a[e].metas=h)}else s[e]=t}),this.syncDiff(r,{joins:s,leaves:a},n,i)}},{key:"syncDiff",value:function(e,t,n,o){var r=t.joins,s=t.leaves,a=this.clone(e);return n||(n=function(){}),o||(o=function(){}),this.map(r,function(e,t){var o=a[e];if(a[e]=t,o){var r,s=a[e].metas.map(function(e){return e.phx_ref}),c=o.metas.filter(function(e){return s.indexOf(e.phx_ref)<0});(r=a[e].metas).unshift.apply(r,i(c))}n(e,o,t)}),this.map(s,function(e,t){var n=a[e];if(n){var i=t.metas.map(function(e){return e.phx_ref});n.metas=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0}),o(e,n,t),0===n.metas.length&&delete a[e]}}),a}},{key:"list",value:function(e,t){return t||(t=function(e,t){return t}),this.map(e,function(e,n){return t(e,n)})}},{key:"map",value:function(e,t){return Object.getOwnPropertyNames(e).map(function(n){return t(n,e[n])})}},{key:"clone",value:function(e){return JSON.parse(JSON.stringify(e))}}]),e}(),E=function(){function e(t,n){s(this,e),this.callback=t,this.timerCalc=n,this.timer=null,this.tries=0}return c(e,[{key:"reset",value:function(){this.tries=0,clearTimeout(this.timer)}},{key:"scheduleTimeout",value:function(){var e=this;clearTimeout(this.timer),this.timer=setTimeout(function(){e.tries=e.tries+1,e.callback()},this.timerCalc(this.tries+1))}}]),e}()}])});
\ No newline at end of file
diff --git a/lib/phoenix/socket.ex b/lib/phoenix/socket.ex
index b9be8c4ff..3945874ce 100644
--- a/lib/phoenix/socket.ex
+++ b/lib/phoenix/socket.ex
@@ -627,6 +627,16 @@ defmodule Phoenix.Socket do
     {:ok, {state, socket}}
   end
 
+  defp handle_in(nil, %{event: "phx_leave", ref: ref, topic: topic}, state, socket) do
+    reply = %Reply{
+      ref: ref,
+      topic: topic,
+      status: :ok,
+      payload: %{}
+    }
+    {:reply, :ok, encode_reply(socket, reply), {state, socket}}
+  end
+
   defp handle_in(nil, message, state, socket) do
     {:reply, :error, encode_ignore(socket, message), {state, socket}}
   end
diff --git a/lib/phoenix/socket/transport.ex b/lib/phoenix/socket/transport.ex
index 716f971a5..2483b441e 100644
--- a/lib/phoenix/socket/transport.ex
+++ b/lib/phoenix/socket/transport.ex
@@ -10,19 +10,28 @@ defmodule Phoenix.Socket.Transport do
 
   Developers interested in implementing custom transports must invoke
   the socket API defined in this module. This module also provides
-  many conveniences to make it easier to build custom transports.
+  many conveniences that invokes the underlying socket API to make
+  it easier to build custom transports.
 
-  ## Workflow
+  ## Booting sockets
 
   Whenever your endpoint starts, it will automatically invoke the
   `child_spec/1` on each listed socket and start that specification
-  under the endpoint supervisor. For this reason, custom transports
-  that are manually started in the supervision tree must be listed
-  after the endpoint.
+  under the endpoint supervisor.
 
-  Whenever the transport receives a connection, it should invoke the
-  `c:connect/1` callback with a map of metadata. Different sockets may
-  require different metadatas.
+  Since the socket supervision tree is started by the endpoint,
+  any custom transport must be started after the endpoint in a
+  supervision tree.
+
+  ## Operating sockets
+
+  Sockets are operated by a transport. When a transport is defined,
+  it usually receives a socket module and the module will be invoked
+  when certain events happen at the transport level.
+
+  Whenever the transport receives a new connection, it should invoke
+  the `c:connect/1` callback with a map of metadata. Different sockets
+  may require different metadatas.
 
   If the connection is accepted, the transport can move the connection
   to another process, if so desires, or keep using the same process. The
diff --git a/priv/static/phoenix.js b/priv/static/phoenix.js
index d26df0cc2..e93f5142b 100644
--- a/priv/static/phoenix.js
+++ b/priv/static/phoenix.js
@@ -1,1 +1,1 @@
-!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Phoenix=t():e.Phoenix=t()}(this,function(){return function(e){var t={};function n(i){if(t[i])return t[i].exports;var o=t[i]={i:i,l:!1,exports:{}};return e[i].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(i,o,function(t){return e[t]}.bind(null,o));return i},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){(function(t){e.exports=t.Phoenix=n(2)}).call(this,n(1))},function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";function i(e){return function(e){if(Array.isArray(e)){for(var t=0,n=new Array(e.length);t<e.length;t++)n[t]=e[t];return n}}(e)||function(e){if(Symbol.iterator in Object(e)||"[object Arguments]"===Object.prototype.toString.call(e))return Array.from(e)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance")}()}function o(e){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function r(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=[],i=!0,o=!1,r=void 0;try{for(var s,a=e[Symbol.iterator]();!(i=(s=a.next()).done)&&(n.push(s.value),!t||n.length!==t);i=!0);}catch(e){o=!0,r=e}finally{try{i||null==a.return||a.return()}finally{if(o)throw r}}return n}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function a(e,t){for(var n=0;n<t.length;n++){var i=t[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}function c(e,t,n){return t&&a(e.prototype,t),n&&a(e,n),e}n.r(t),n.d(t,"Channel",function(){return b}),n.d(t,"Socket",function(){return R}),n.d(t,"LongPoll",function(){return w}),n.d(t,"Ajax",function(){return C}),n.d(t,"Presence",function(){return S});var u="undefined"!=typeof self?self:null,h="undefined"!=typeof window?window:null,l=u||h||void 0,f={connecting:0,open:1,closing:2,closed:3},p=1e4,d={closed:"closed",errored:"errored",joined:"joined",joining:"joining",leaving:"leaving"},v={close:"phx_close",error:"phx_error",join:"phx_join",reply:"phx_reply",leave:"phx_leave"},y=[v.close,v.error,v.join,v.reply,v.leave],g={longpoll:"longpoll",websocket:"websocket"},m=function(e){if("function"==typeof e)return e;return function(){return e}},k=function(){function e(t,n,i,o){s(this,e),this.channel=t,this.event=n,this.payload=i||function(){return{}},this.receivedResp=null,this.timeout=o,this.timeoutTimer=null,this.recHooks=[],this.sent=!1}return c(e,[{key:"resend",value:function(e){this.timeout=e,this.reset(),this.send()}},{key:"send",value:function(){this.hasReceived("timeout")||(this.startTimeout(),this.sent=!0,this.channel.socket.push({topic:this.channel.topic,event:this.event,payload:this.payload(),ref:this.ref,join_ref:this.channel.joinRef()}))}},{key:"receive",value:function(e,t){return this.hasReceived(e)&&t(this.receivedResp.response),this.recHooks.push({status:e,callback:t}),this}},{key:"reset",value:function(){this.cancelRefEvent(),this.ref=null,this.refEvent=null,this.receivedResp=null,this.sent=!1}},{key:"matchReceive",value:function(e){var t=e.status,n=e.response;e.ref;this.recHooks.filter(function(e){return e.status===t}).forEach(function(e){return e.callback(n)})}},{key:"cancelRefEvent",value:function(){this.refEvent&&this.channel.off(this.refEvent)}},{key:"cancelTimeout",value:function(){clearTimeout(this.timeoutTimer),this.timeoutTimer=null}},{key:"startTimeout",value:function(){var e=this;this.timeoutTimer&&this.cancelTimeout(),this.ref=this.channel.socket.makeRef(),this.refEvent=this.channel.replyEventName(this.ref),this.channel.on(this.refEvent,function(t){e.cancelRefEvent(),e.cancelTimeout(),e.receivedResp=t,e.matchReceive(t)}),this.timeoutTimer=setTimeout(function(){e.trigger("timeout",{})},this.timeout)}},{key:"hasReceived",value:function(e){return this.receivedResp&&this.receivedResp.status===e}},{key:"trigger",value:function(e,t){this.channel.trigger(this.refEvent,{status:e,response:t})}}]),e}(),b=function(){function e(t,n,i){var o=this;s(this,e),this.state=d.closed,this.topic=t,this.params=m(n||{}),this.socket=i,this.bindings=[],this.bindingRef=0,this.timeout=this.socket.timeout,this.joinedOnce=!1,this.joinPush=new k(this,v.join,this.params,this.timeout),this.pushBuffer=[],this.rejoinTimer=new T(function(){return o.rejoinUntilConnected()},this.socket.reconnectAfterMs),this.joinPush.receive("ok",function(){o.state=d.joined,o.rejoinTimer.reset(),o.pushBuffer.forEach(function(e){return e.send()}),o.pushBuffer=[]}),this.onClose(function(){o.rejoinTimer.reset(),o.socket.hasLogger()&&o.socket.log("channel","close ".concat(o.topic," ").concat(o.joinRef())),o.state=d.closed,o.socket.remove(o)}),this.onError(function(e){o.isLeaving()||o.isClosed()||(o.socket.hasLogger()&&o.socket.log("channel","error ".concat(o.topic),e),o.state=d.errored,o.rejoinTimer.scheduleTimeout())}),this.joinPush.receive("timeout",function(){o.isJoining()&&(o.socket.hasLogger()&&o.socket.log("channel","timeout ".concat(o.topic," (").concat(o.joinRef(),")"),o.joinPush.timeout),new k(o,v.leave,m({}),o.timeout).send(),o.state=d.errored,o.joinPush.reset(),o.rejoinTimer.scheduleTimeout())}),this.on(v.reply,function(e,t){o.trigger(o.replyEventName(t),e)})}return c(e,[{key:"rejoinUntilConnected",value:function(){this.rejoinTimer.scheduleTimeout(),this.socket.isConnected()&&this.rejoin()}},{key:"join",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;if(this.joinedOnce)throw new Error("tried to join multiple times. 'join' can only be called a single time per channel instance");return this.joinedOnce=!0,this.rejoin(e),this.joinPush}},{key:"onClose",value:function(e){this.on(v.close,e)}},{key:"onError",value:function(e){return this.on(v.error,function(t){return e(t)})}},{key:"on",value:function(e,t){var n=this.bindingRef++;return this.bindings.push({event:e,ref:n,callback:t}),n}},{key:"off",value:function(e,t){this.bindings=this.bindings.filter(function(n){return!(n.event===e&&(void 0===t||t===n.ref))})}},{key:"canPush",value:function(){return this.socket.isConnected()&&this.isJoined()}},{key:"push",value:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.timeout;if(!this.joinedOnce)throw new Error("tried to push '".concat(e,"' to '").concat(this.topic,"' before joining. Use channel.join() before pushing events"));var i=new k(this,e,function(){return t},n);return this.canPush()?i.send():(i.startTimeout(),this.pushBuffer.push(i)),i}},{key:"leave",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.state=d.leaving;var n=function(){e.socket.hasLogger()&&e.socket.log("channel","leave ".concat(e.topic)),e.trigger(v.close,"leave")},i=new k(this,v.leave,m({}),t);return i.receive("ok",function(){return n()}).receive("timeout",function(){return n()}),i.send(),this.canPush()||i.trigger("ok",{}),i}},{key:"onMessage",value:function(e,t,n){return t}},{key:"isLifecycleEvent",value:function(e){return y.indexOf(e)>=0}},{key:"isMember",value:function(e,t,n,i){return this.topic===e&&(!i||i===this.joinRef()||!this.isLifecycleEvent(t)||(this.socket.hasLogger()&&this.socket.log("channel","dropping outdated message",{topic:e,event:t,payload:n,joinRef:i}),!1))}},{key:"joinRef",value:function(){return this.joinPush.ref}},{key:"sendJoin",value:function(e){this.state=d.joining,this.joinPush.resend(e)}},{key:"rejoin",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.isLeaving()||this.sendJoin(e)}},{key:"trigger",value:function(e,t,n,i){var o=this.onMessage(e,t,n,i);if(t&&!o)throw new Error("channel onMessage callbacks must return the payload, modified or unmodified");for(var r=0;r<this.bindings.length;r++){var s=this.bindings[r];s.event===e&&s.callback(o,n,i||this.joinRef())}}},{key:"replyEventName",value:function(e){return"chan_reply_".concat(e)}},{key:"isClosed",value:function(){return this.state===d.closed}},{key:"isErrored",value:function(){return this.state===d.errored}},{key:"isJoined",value:function(){return this.state===d.joined}},{key:"isJoining",value:function(){return this.state===d.joining}},{key:"isLeaving",value:function(){return this.state===d.leaving}}]),e}(),j={encode:function(e,t){var n=[e.join_ref,e.ref,e.topic,e.event,e.payload];return t(JSON.stringify(n))},decode:function(e,t){var n=r(JSON.parse(e),5);return t({join_ref:n[0],ref:n[1],topic:n[2],event:n[3],payload:n[4]})}},R=function(){function e(t){var n=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};s(this,e),this.stateChangeCallbacks={open:[],close:[],error:[],message:[]},this.channels=[],this.sendBuffer=[],this.ref=0,this.timeout=i.timeout||p,this.transport=i.transport||l.WebSocket||w,this.defaultEncoder=j.encode,this.defaultDecoder=j.decode,this.transport!==w?(this.encode=i.encode||this.defaultEncoder,this.decode=i.decode||this.defaultDecoder):(this.encode=this.defaultEncoder,this.decode=this.defaultDecoder),this.heartbeatIntervalMs=i.heartbeatIntervalMs||3e4,this.reconnectAfterMs=i.reconnectAfterMs||function(e){return[1e3,2e3,5e3,1e4][e-1]||1e4},this.logger=i.logger||null,this.longpollerTimeout=i.longpollerTimeout||2e4,this.params=m(i.params||{}),this.endPoint="".concat(t,"/").concat(g.websocket),this.heartbeatTimer=null,this.pendingHeartbeatRef=null,this.reconnectTimer=new T(function(){n.teardown(function(){return n.connect()})},this.reconnectAfterMs)}return c(e,[{key:"protocol",value:function(){return location.protocol.match(/^https/)?"wss":"ws"}},{key:"endPointURL",value:function(){var e=C.appendParams(C.appendParams(this.endPoint,this.params()),{vsn:"2.0.0"});return"/"!==e.charAt(0)?e:"/"===e.charAt(1)?"".concat(this.protocol(),":").concat(e):"".concat(this.protocol(),"://").concat(location.host).concat(e)}},{key:"disconnect",value:function(e,t,n){this.reconnectTimer.reset(),this.teardown(e,t,n)}},{key:"connect",value:function(e){var t=this;e&&(console&&console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"),this.params=m(e)),this.conn||(this.conn=new this.transport(this.endPointURL()),this.conn.timeout=this.longpollerTimeout,this.conn.onopen=function(){return t.onConnOpen()},this.conn.onerror=function(e){return t.onConnError(e)},this.conn.onmessage=function(e){return t.onConnMessage(e)},this.conn.onclose=function(e){return t.onConnClose(e)})}},{key:"log",value:function(e,t,n){this.logger(e,t,n)}},{key:"hasLogger",value:function(){return null!==this.logger}},{key:"onOpen",value:function(e){this.stateChangeCallbacks.open.push(e)}},{key:"onClose",value:function(e){this.stateChangeCallbacks.close.push(e)}},{key:"onError",value:function(e){this.stateChangeCallbacks.error.push(e)}},{key:"onMessage",value:function(e){this.stateChangeCallbacks.message.push(e)}},{key:"onConnOpen",value:function(){this.hasLogger()&&this.log("transport","connected to ".concat(this.endPointURL())),this.flushSendBuffer(),this.reconnectTimer.reset(),this.resetHeartbeat(),this.stateChangeCallbacks.open.forEach(function(e){return e()})}},{key:"resetHeartbeat",value:function(){var e=this;this.conn.skipHeartbeat||(this.pendingHeartbeatRef=null,clearInterval(this.heartbeatTimer),this.heartbeatTimer=setInterval(function(){return e.sendHeartbeat()},this.heartbeatIntervalMs))}},{key:"teardown",value:function(e,t,n){this.conn&&(this.conn.onclose=function(){},t?this.conn.close(t,n||""):this.conn.close(),this.conn=null),e&&e()}},{key:"onConnClose",value:function(e){this.hasLogger()&&this.log("transport","close",e),this.triggerChanError(),clearInterval(this.heartbeatTimer),e&&1e3!==e.code&&this.reconnectTimer.scheduleTimeout(),this.stateChangeCallbacks.close.forEach(function(t){return t(e)})}},{key:"onConnError",value:function(e){this.hasLogger()&&this.log("transport",e),this.triggerChanError(),this.stateChangeCallbacks.error.forEach(function(t){return t(e)})}},{key:"triggerChanError",value:function(){this.channels.forEach(function(e){return e.trigger(v.error)})}},{key:"connectionState",value:function(){switch(this.conn&&this.conn.readyState){case f.connecting:return"connecting";case f.open:return"open";case f.closing:return"closing";default:return"closed"}}},{key:"isConnected",value:function(){return"open"===this.connectionState()}},{key:"remove",value:function(e){this.channels=this.channels.filter(function(t){return t.joinRef()!==e.joinRef()})}},{key:"channel",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=new b(e,t,this);return this.channels.push(n),n}},{key:"push",value:function(e){var t=this;if(this.hasLogger()){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;this.log("push","".concat(n," ").concat(i," (").concat(s,", ").concat(r,")"),o)}this.isConnected()?this.encode(e,function(e){return t.conn.send(e)}):this.sendBuffer.push(function(){return t.encode(e,function(e){return t.conn.send(e)})})}},{key:"makeRef",value:function(){var e=this.ref+1;return e===this.ref?this.ref=0:this.ref=e,this.ref.toString()}},{key:"sendHeartbeat",value:function(){if(this.isConnected()){if(this.pendingHeartbeatRef)return this.pendingHeartbeatRef=null,this.hasLogger()&&this.log("transport","heartbeat timeout. Attempting to re-establish connection"),void this.conn.close(1e3,"hearbeat timeout");this.pendingHeartbeatRef=this.makeRef(),this.push({topic:"phoenix",event:"heartbeat",payload:{},ref:this.pendingHeartbeatRef})}}},{key:"flushSendBuffer",value:function(){this.isConnected()&&this.sendBuffer.length>0&&(this.sendBuffer.forEach(function(e){return e()}),this.sendBuffer=[])}},{key:"onConnMessage",value:function(e){var t=this;this.decode(e.data,function(e){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;r&&r===t.pendingHeartbeatRef&&(t.pendingHeartbeatRef=null),t.hasLogger()&&t.log("receive","".concat(o.status||""," ").concat(n," ").concat(i," ").concat(r&&"("+r+")"||""),o);for(var a=0;a<t.channels.length;a++){var c=t.channels[a];c.isMember(n,i,o,s)&&c.trigger(i,o,r,s)}for(var u=0;u<t.stateChangeCallbacks.message.length;u++)t.stateChangeCallbacks.message[u](e)})}}]),e}(),w=function(){function e(t){s(this,e),this.endPoint=null,this.token=null,this.skipHeartbeat=!0,this.onopen=function(){},this.onerror=function(){},this.onmessage=function(){},this.onclose=function(){},this.pollEndpoint=this.normalizeEndpoint(t),this.readyState=f.connecting,this.poll()}return c(e,[{key:"normalizeEndpoint",value:function(e){return e.replace("ws://","http://").replace("wss://","https://").replace(new RegExp("(.*)/"+g.websocket),"$1/"+g.longpoll)}},{key:"endpointURL",value:function(){return C.appendParams(this.pollEndpoint,{token:this.token})}},{key:"closeAndRetry",value:function(){this.close(),this.readyState=f.connecting}},{key:"ontimeout",value:function(){this.onerror("timeout"),this.closeAndRetry()}},{key:"poll",value:function(){var e=this;this.readyState!==f.open&&this.readyState!==f.connecting||C.request("GET",this.endpointURL(),"application/json",null,this.timeout,this.ontimeout.bind(this),function(t){if(t){var n=t.status,i=t.token,o=t.messages;e.token=i}else n=0;switch(n){case 200:o.forEach(function(t){return e.onmessage({data:t})}),e.poll();break;case 204:e.poll();break;case 410:e.readyState=f.open,e.onopen(),e.poll();break;case 0:case 500:e.onerror(),e.closeAndRetry();break;default:throw new Error("unhandled poll status ".concat(n))}})}},{key:"send",value:function(e){var t=this;C.request("POST",this.endpointURL(),"application/json",e,this.timeout,this.onerror.bind(this,"timeout"),function(e){e&&200===e.status||(t.onerror(e&&e.status),t.closeAndRetry())})}},{key:"close",value:function(e,t){this.readyState=f.closed,this.onclose()}}]),e}(),C=function(){function e(){s(this,e)}return c(e,null,[{key:"request",value:function(e,t,n,i,o,r,s){if(l.XDomainRequest){var a=new XDomainRequest;this.xdomainRequest(a,e,t,i,o,r,s)}else{var c=l.XMLHttpRequest?new l.XMLHttpRequest:new ActiveXObject("Microsoft.XMLHTTP");this.xhrRequest(c,e,t,n,i,o,r,s)}}},{key:"xdomainRequest",value:function(e,t,n,i,o,r,s){var a=this;e.timeout=o,e.open(t,n),e.onload=function(){var t=a.parseJSON(e.responseText);s&&s(t)},r&&(e.ontimeout=r),e.onprogress=function(){},e.send(i)}},{key:"xhrRequest",value:function(e,t,n,i,o,r,s,a){var c=this;e.open(t,n,!0),e.timeout=r,e.setRequestHeader("Content-Type",i),e.onerror=function(){a&&a(null)},e.onreadystatechange=function(){if(e.readyState===c.states.complete&&a){var t=c.parseJSON(e.responseText);a(t)}},s&&(e.ontimeout=s),e.send(o)}},{key:"parseJSON",value:function(e){if(!e||""===e)return null;try{return JSON.parse(e)}catch(t){return console&&console.log("failed to parse JSON response",e),null}}},{key:"serialize",value:function(e,t){var n=[];for(var i in e)if(e.hasOwnProperty(i)){var r=t?"".concat(t,"[").concat(i,"]"):i,s=e[i];"object"===o(s)?n.push(this.serialize(s,r)):n.push(encodeURIComponent(r)+"="+encodeURIComponent(s))}return n.join("&")}},{key:"appendParams",value:function(e,t){if(0===Object.keys(t).length)return e;var n=e.match(/\?/)?"&":"?";return"".concat(e).concat(n).concat(this.serialize(t))}}]),e}();C.states={complete:4};var S=function(){function e(t){var n=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};s(this,e);var o=i.events||{state:"presence_state",diff:"presence_diff"};this.state={},this.pendingDiffs=[],this.channel=t,this.joinRef=null,this.caller={onJoin:function(){},onLeave:function(){},onSync:function(){}},this.channel.on(o.state,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.joinRef=n.channel.joinRef(),n.state=e.syncState(n.state,t,o,r),n.pendingDiffs.forEach(function(t){n.state=e.syncDiff(n.state,t,o,r)}),n.pendingDiffs=[],s()}),this.channel.on(o.diff,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.inPendingSyncState()?n.pendingDiffs.push(t):(n.state=e.syncDiff(n.state,t,o,r),s())})}return c(e,[{key:"onJoin",value:function(e){this.caller.onJoin=e}},{key:"onLeave",value:function(e){this.caller.onLeave=e}},{key:"onSync",value:function(e){this.caller.onSync=e}},{key:"list",value:function(t){return e.list(this.state,t)}},{key:"inPendingSyncState",value:function(){return!this.joinRef||this.joinRef!==this.channel.joinRef()}}],[{key:"syncState",value:function(e,t,n,i){var o=this,r=this.clone(e),s={},a={};return this.map(r,function(e,n){t[e]||(a[e]=n)}),this.map(t,function(e,t){var n=r[e];if(n){var i=t.metas.map(function(e){return e.phx_ref}),c=n.metas.map(function(e){return e.phx_ref}),u=t.metas.filter(function(e){return c.indexOf(e.phx_ref)<0}),h=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0});u.length>0&&(s[e]=t,s[e].metas=u),h.length>0&&(a[e]=o.clone(n),a[e].metas=h)}else s[e]=t}),this.syncDiff(r,{joins:s,leaves:a},n,i)}},{key:"syncDiff",value:function(e,t,n,o){var r=t.joins,s=t.leaves,a=this.clone(e);return n||(n=function(){}),o||(o=function(){}),this.map(r,function(e,t){var o=a[e];if(a[e]=t,o){var r,s=a[e].metas.map(function(e){return e.phx_ref}),c=o.metas.filter(function(e){return s.indexOf(e.phx_ref)<0});(r=a[e].metas).unshift.apply(r,i(c))}n(e,o,t)}),this.map(s,function(e,t){var n=a[e];if(n){var i=t.metas.map(function(e){return e.phx_ref});n.metas=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0}),o(e,n,t),0===n.metas.length&&delete a[e]}}),a}},{key:"list",value:function(e,t){return t||(t=function(e,t){return t}),this.map(e,function(e,n){return t(e,n)})}},{key:"map",value:function(e,t){return Object.getOwnPropertyNames(e).map(function(n){return t(n,e[n])})}},{key:"clone",value:function(e){return JSON.parse(JSON.stringify(e))}}]),e}(),T=function(){function e(t,n){s(this,e),this.callback=t,this.timerCalc=n,this.timer=null,this.tries=0}return c(e,[{key:"reset",value:function(){this.tries=0,clearTimeout(this.timer)}},{key:"scheduleTimeout",value:function(){var e=this;clearTimeout(this.timer),this.timer=setTimeout(function(){e.tries=e.tries+1,e.callback()},this.timerCalc(this.tries+1))}}]),e}()}])});
\ No newline at end of file
+!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Phoenix=t():e.Phoenix=t()}(this,function(){return function(e){var t={};function n(i){if(t[i])return t[i].exports;var o=t[i]={i:i,l:!1,exports:{}};return e[i].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(i,o,function(t){return e[t]}.bind(null,o));return i},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){(function(t){e.exports=t.Phoenix=n(2)}).call(this,n(1))},function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";function i(e){return function(e){if(Array.isArray(e)){for(var t=0,n=new Array(e.length);t<e.length;t++)n[t]=e[t];return n}}(e)||function(e){if(Symbol.iterator in Object(e)||"[object Arguments]"===Object.prototype.toString.call(e))return Array.from(e)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance")}()}function o(e){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function r(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=[],i=!0,o=!1,r=void 0;try{for(var s,a=e[Symbol.iterator]();!(i=(s=a.next()).done)&&(n.push(s.value),!t||n.length!==t);i=!0);}catch(e){o=!0,r=e}finally{try{i||null==a.return||a.return()}finally{if(o)throw r}}return n}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function a(e,t){for(var n=0;n<t.length;n++){var i=t[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}function c(e,t,n){return t&&a(e.prototype,t),n&&a(e,n),e}n.r(t),n.d(t,"Channel",function(){return b}),n.d(t,"Socket",function(){return R}),n.d(t,"LongPoll",function(){return C}),n.d(t,"Ajax",function(){return T}),n.d(t,"Presence",function(){return w});var u="undefined"!=typeof self?self:null,h="undefined"!=typeof window?window:null,l=u||h||void 0,f={connecting:0,open:1,closing:2,closed:3},d=1e4,p={closed:"closed",errored:"errored",joined:"joined",joining:"joining",leaving:"leaving"},v={close:"phx_close",error:"phx_error",join:"phx_join",reply:"phx_reply",leave:"phx_leave"},y=[v.close,v.error,v.join,v.reply,v.leave],m={longpoll:"longpoll",websocket:"websocket"},g=function(e){if("function"==typeof e)return e;return function(){return e}},k=function(){function e(t,n,i,o){s(this,e),this.channel=t,this.event=n,this.payload=i||function(){return{}},this.receivedResp=null,this.timeout=o,this.timeoutTimer=null,this.recHooks=[],this.sent=!1}return c(e,[{key:"resend",value:function(e){this.timeout=e,this.reset(),this.send()}},{key:"send",value:function(){this.hasReceived("timeout")||(this.startTimeout(),this.sent=!0,this.channel.socket.push({topic:this.channel.topic,event:this.event,payload:this.payload(),ref:this.ref,join_ref:this.channel.joinRef()}))}},{key:"receive",value:function(e,t){return this.hasReceived(e)&&t(this.receivedResp.response),this.recHooks.push({status:e,callback:t}),this}},{key:"reset",value:function(){this.cancelRefEvent(),this.ref=null,this.refEvent=null,this.receivedResp=null,this.sent=!1}},{key:"matchReceive",value:function(e){var t=e.status,n=e.response;e.ref;this.recHooks.filter(function(e){return e.status===t}).forEach(function(e){return e.callback(n)})}},{key:"cancelRefEvent",value:function(){this.refEvent&&this.channel.off(this.refEvent)}},{key:"cancelTimeout",value:function(){clearTimeout(this.timeoutTimer),this.timeoutTimer=null}},{key:"startTimeout",value:function(){var e=this;this.timeoutTimer&&this.cancelTimeout(),this.ref=this.channel.socket.makeRef(),this.refEvent=this.channel.replyEventName(this.ref),this.channel.on(this.refEvent,function(t){e.cancelRefEvent(),e.cancelTimeout(),e.receivedResp=t,e.matchReceive(t)}),this.timeoutTimer=setTimeout(function(){e.trigger("timeout",{})},this.timeout)}},{key:"hasReceived",value:function(e){return this.receivedResp&&this.receivedResp.status===e}},{key:"trigger",value:function(e,t){this.channel.trigger(this.refEvent,{status:e,response:t})}}]),e}(),b=function(){function e(t,n,i){var o=this;s(this,e),this.state=p.closed,this.topic=t,this.params=g(n||{}),this.socket=i,this.bindings=[],this.bindingRef=0,this.timeout=this.socket.timeout,this.joinedOnce=!1,this.joinPush=new k(this,v.join,this.params,this.timeout),this.pushBuffer=[],this.rejoinTimer=new E(function(){o.socket.isConnected()&&o.rejoin()},this.socket.rejoinAfterMs),this.socket.onError(function(){return o.rejoinTimer.reset()}),this.socket.onOpen(function(){o.rejoinTimer.reset(),o.isErrored()&&o.rejoin()}),this.joinPush.receive("ok",function(){o.state=p.joined,o.rejoinTimer.reset(),o.pushBuffer.forEach(function(e){return e.send()}),o.pushBuffer=[]}),this.joinPush.receive("error",function(){o.state=p.errored,o.socket.isConnected()&&o.rejoinTimer.scheduleTimeout()}),this.onClose(function(){o.rejoinTimer.reset(),o.socket.hasLogger()&&o.socket.log("channel","close ".concat(o.topic," ").concat(o.joinRef())),o.state=p.closed,o.socket.remove(o)}),this.onError(function(e){o.socket.hasLogger()&&o.socket.log("channel","error ".concat(o.topic),e),o.isJoining()&&o.joinPush.reset(),o.state=p.errored,o.socket.isConnected()&&o.rejoinTimer.scheduleTimeout()}),this.joinPush.receive("timeout",function(){o.socket.hasLogger()&&o.socket.log("channel","timeout ".concat(o.topic," (").concat(o.joinRef(),")"),o.joinPush.timeout),new k(o,v.leave,g({}),o.timeout).send(),o.state=p.errored,o.joinPush.reset(),o.socket.isConnected()&&o.rejoinTimer.scheduleTimeout()}),this.on(v.reply,function(e,t){o.trigger(o.replyEventName(t),e)})}return c(e,[{key:"join",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;if(this.joinedOnce)throw new Error("tried to join multiple times. 'join' can only be called a single time per channel instance");return this.timeout=e,this.joinedOnce=!0,this.rejoin(),this.joinPush}},{key:"onClose",value:function(e){this.on(v.close,e)}},{key:"onError",value:function(e){return this.on(v.error,function(t){return e(t)})}},{key:"on",value:function(e,t){var n=this.bindingRef++;return this.bindings.push({event:e,ref:n,callback:t}),n}},{key:"off",value:function(e,t){this.bindings=this.bindings.filter(function(n){return!(n.event===e&&(void 0===t||t===n.ref))})}},{key:"canPush",value:function(){return this.socket.isConnected()&&this.isJoined()}},{key:"push",value:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.timeout;if(!this.joinedOnce)throw new Error("tried to push '".concat(e,"' to '").concat(this.topic,"' before joining. Use channel.join() before pushing events"));var i=new k(this,e,function(){return t},n);return this.canPush()?i.send():(i.startTimeout(),this.pushBuffer.push(i)),i}},{key:"leave",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.rejoinTimer.reset(),this.state=p.leaving;var n=function(){e.socket.hasLogger()&&e.socket.log("channel","leave ".concat(e.topic)),e.trigger(v.close,"leave")},i=new k(this,v.leave,g({}),t);return i.receive("ok",function(){return n()}).receive("timeout",function(){return n()}),i.send(),this.canPush()||i.trigger("ok",{}),i}},{key:"onMessage",value:function(e,t,n){return t}},{key:"isLifecycleEvent",value:function(e){return y.indexOf(e)>=0}},{key:"isMember",value:function(e,t,n,i){return this.topic===e&&(!i||i===this.joinRef()||!this.isLifecycleEvent(t)||(this.socket.hasLogger()&&this.socket.log("channel","dropping outdated message",{topic:e,event:t,payload:n,joinRef:i}),!1))}},{key:"joinRef",value:function(){return this.joinPush.ref}},{key:"sendJoin",value:function(e){this.state=p.joining,this.joinPush.resend(e)}},{key:"rejoin",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.isLeaving()||this.sendJoin(e)}},{key:"trigger",value:function(e,t,n,i){var o=this.onMessage(e,t,n,i);if(t&&!o)throw new Error("channel onMessage callbacks must return the payload, modified or unmodified");for(var r=0;r<this.bindings.length;r++){var s=this.bindings[r];s.event===e&&s.callback(o,n,i||this.joinRef())}}},{key:"replyEventName",value:function(e){return"chan_reply_".concat(e)}},{key:"isClosed",value:function(){return this.state===p.closed}},{key:"isErrored",value:function(){return this.state===p.errored}},{key:"isJoined",value:function(){return this.state===p.joined}},{key:"isJoining",value:function(){return this.state===p.joining}},{key:"isLeaving",value:function(){return this.state===p.leaving}}]),e}(),j={encode:function(e,t){var n=[e.join_ref,e.ref,e.topic,e.event,e.payload];return t(JSON.stringify(n))},decode:function(e,t){var n=r(JSON.parse(e),5);return t({join_ref:n[0],ref:n[1],topic:n[2],event:n[3],payload:n[4]})}},R=function(){function e(t){var n=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};s(this,e),this.stateChangeCallbacks={open:[],close:[],error:[],message:[]},this.channels=[],this.sendBuffer=[],this.ref=0,this.timeout=i.timeout||d,this.transport=i.transport||l.WebSocket||C,this.defaultEncoder=j.encode,this.defaultDecoder=j.decode,this.closeWasClean=!1,this.unloaded=!1,this.binaryType=i.binaryType||"arraybuffer",this.transport!==C?(this.encode=i.encode||this.defaultEncoder,this.decode=i.decode||this.defaultDecoder):(this.encode=this.defaultEncoder,this.decode=this.defaultDecoder),h&&h.addEventListener("beforeunload",function(e){n.unloaded=!0,n.abnormalClose("unloaded")}),this.heartbeatIntervalMs=i.heartbeatIntervalMs||3e4,this.rejoinAfterMs=function(e){return i.rejoinAfterMs?i.rejoinAfterMs(e):[1e3,2e3,5e3][e-1]||1e4},this.reconnectAfterMs=function(e){return n.unloaded?100:i.reconnectAfterMs?i.reconnectAfterMs(e):[10,50,100,150,200,250,500,1e3,2e3][e-1]||5e3},this.logger=i.logger||null,this.longpollerTimeout=i.longpollerTimeout||2e4,this.params=g(i.params||{}),this.endPoint="".concat(t,"/").concat(m.websocket),this.heartbeatTimer=null,this.pendingHeartbeatRef=null,this.reconnectTimer=new E(function(){n.teardown(function(){return n.connect()})},this.reconnectAfterMs)}return c(e,[{key:"protocol",value:function(){return location.protocol.match(/^https/)?"wss":"ws"}},{key:"endPointURL",value:function(){var e=T.appendParams(T.appendParams(this.endPoint,this.params()),{vsn:"2.0.0"});return"/"!==e.charAt(0)?e:"/"===e.charAt(1)?"".concat(this.protocol(),":").concat(e):"".concat(this.protocol(),"://").concat(location.host).concat(e)}},{key:"disconnect",value:function(e,t,n){this.closeWasClean=!0,this.reconnectTimer.reset(),this.teardown(e,t,n)}},{key:"connect",value:function(e){var t=this;e&&(console&&console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"),this.params=g(e)),this.conn||(this.conn=new this.transport(this.endPointURL()),this.conn.binaryType=this.binaryType,this.conn.timeout=this.longpollerTimeout,this.conn.onopen=function(){return t.onConnOpen()},this.conn.onerror=function(e){return t.onConnError(e)},this.conn.onmessage=function(e){return t.onConnMessage(e)},this.conn.onclose=function(e){return t.onConnClose(e)})}},{key:"log",value:function(e,t,n){this.logger(e,t,n)}},{key:"hasLogger",value:function(){return null!==this.logger}},{key:"onOpen",value:function(e){this.stateChangeCallbacks.open.push(e)}},{key:"onClose",value:function(e){this.stateChangeCallbacks.close.push(e)}},{key:"onError",value:function(e){this.stateChangeCallbacks.error.push(e)}},{key:"onMessage",value:function(e){this.stateChangeCallbacks.message.push(e)}},{key:"onConnOpen",value:function(){this.hasLogger()&&this.log("transport","connected to ".concat(this.endPointURL())),this.unloaded=!1,this.closeWasClean=!1,this.flushSendBuffer(),this.reconnectTimer.reset(),this.resetHeartbeat(),this.stateChangeCallbacks.open.forEach(function(e){return e()})}},{key:"resetHeartbeat",value:function(){var e=this;this.conn&&this.conn.skipHeartbeat||(this.pendingHeartbeatRef=null,clearInterval(this.heartbeatTimer),this.heartbeatTimer=setInterval(function(){return e.sendHeartbeat()},this.heartbeatIntervalMs))}},{key:"teardown",value:function(e,t,n){this.conn&&(this.conn.onclose=function(){},t?this.conn.close(t,n||""):this.conn.close(),this.conn=null),e&&e()}},{key:"onConnClose",value:function(e){this.hasLogger()&&this.log("transport","close",e),this.triggerChanError(),clearInterval(this.heartbeatTimer),this.closeWasClean||this.reconnectTimer.scheduleTimeout(),this.stateChangeCallbacks.close.forEach(function(t){return t(e)})}},{key:"onConnError",value:function(e){this.hasLogger()&&this.log("transport",e),this.triggerChanError(),this.stateChangeCallbacks.error.forEach(function(t){return t(e)})}},{key:"triggerChanError",value:function(){this.channels.forEach(function(e){e.isErrored()||e.isLeaving()||e.isClosed()||e.trigger(v.error)})}},{key:"connectionState",value:function(){switch(this.conn&&this.conn.readyState){case f.connecting:return"connecting";case f.open:return"open";case f.closing:return"closing";default:return"closed"}}},{key:"isConnected",value:function(){return"open"===this.connectionState()}},{key:"remove",value:function(e){this.channels=this.channels.filter(function(t){return t.joinRef()!==e.joinRef()})}},{key:"channel",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=new b(e,t,this);return this.channels.push(n),n}},{key:"push",value:function(e){var t=this;if(this.hasLogger()){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;this.log("push","".concat(n," ").concat(i," (").concat(s,", ").concat(r,")"),o)}this.isConnected()?this.encode(e,function(e){return t.conn.send(e)}):this.sendBuffer.push(function(){return t.encode(e,function(e){return t.conn.send(e)})})}},{key:"makeRef",value:function(){var e=this.ref+1;return e===this.ref?this.ref=0:this.ref=e,this.ref.toString()}},{key:"sendHeartbeat",value:function(){if(this.isConnected()){if(this.pendingHeartbeatRef)return this.pendingHeartbeatRef=null,this.hasLogger()&&this.log("transport","heartbeat timeout. Attempting to re-establish connection"),void this.abnormalClose("heartbeat timeout");this.pendingHeartbeatRef=this.makeRef(),this.push({topic:"phoenix",event:"heartbeat",payload:{},ref:this.pendingHeartbeatRef})}}},{key:"abnormalClose",value:function(e){this.closeWasClean=!1,this.conn.close(1e3,e)}},{key:"flushSendBuffer",value:function(){this.isConnected()&&this.sendBuffer.length>0&&(this.sendBuffer.forEach(function(e){return e()}),this.sendBuffer=[])}},{key:"onConnMessage",value:function(e){var t=this;this.decode(e.data,function(e){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;r&&r===t.pendingHeartbeatRef&&(t.pendingHeartbeatRef=null),t.hasLogger()&&t.log("receive","".concat(o.status||""," ").concat(n," ").concat(i," ").concat(r&&"("+r+")"||""),o);for(var a=0;a<t.channels.length;a++){var c=t.channels[a];c.isMember(n,i,o,s)&&c.trigger(i,o,r,s)}for(var u=0;u<t.stateChangeCallbacks.message.length;u++)t.stateChangeCallbacks.message[u](e)})}}]),e}(),C=function(){function e(t){s(this,e),this.endPoint=null,this.token=null,this.skipHeartbeat=!0,this.onopen=function(){},this.onerror=function(){},this.onmessage=function(){},this.onclose=function(){},this.pollEndpoint=this.normalizeEndpoint(t),this.readyState=f.connecting,this.poll()}return c(e,[{key:"normalizeEndpoint",value:function(e){return e.replace("ws://","http://").replace("wss://","https://").replace(new RegExp("(.*)/"+m.websocket),"$1/"+m.longpoll)}},{key:"endpointURL",value:function(){return T.appendParams(this.pollEndpoint,{token:this.token})}},{key:"closeAndRetry",value:function(){this.close(),this.readyState=f.connecting}},{key:"ontimeout",value:function(){this.onerror("timeout"),this.closeAndRetry()}},{key:"poll",value:function(){var e=this;this.readyState!==f.open&&this.readyState!==f.connecting||T.request("GET",this.endpointURL(),"application/json",null,this.timeout,this.ontimeout.bind(this),function(t){if(t){var n=t.status,i=t.token,o=t.messages;e.token=i}else n=0;switch(n){case 200:o.forEach(function(t){return e.onmessage({data:t})}),e.poll();break;case 204:e.poll();break;case 410:e.readyState=f.open,e.onopen(),e.poll();break;case 0:case 500:e.onerror(),e.closeAndRetry();break;default:throw new Error("unhandled poll status ".concat(n))}})}},{key:"send",value:function(e){var t=this;T.request("POST",this.endpointURL(),"application/json",e,this.timeout,this.onerror.bind(this,"timeout"),function(e){e&&200===e.status||(t.onerror(e&&e.status),t.closeAndRetry())})}},{key:"close",value:function(e,t){this.readyState=f.closed,this.onclose()}}]),e}(),T=function(){function e(){s(this,e)}return c(e,null,[{key:"request",value:function(e,t,n,i,o,r,s){if(l.XDomainRequest){var a=new XDomainRequest;this.xdomainRequest(a,e,t,i,o,r,s)}else{var c=l.XMLHttpRequest?new l.XMLHttpRequest:new ActiveXObject("Microsoft.XMLHTTP");this.xhrRequest(c,e,t,n,i,o,r,s)}}},{key:"xdomainRequest",value:function(e,t,n,i,o,r,s){var a=this;e.timeout=o,e.open(t,n),e.onload=function(){var t=a.parseJSON(e.responseText);s&&s(t)},r&&(e.ontimeout=r),e.onprogress=function(){},e.send(i)}},{key:"xhrRequest",value:function(e,t,n,i,o,r,s,a){var c=this;e.open(t,n,!0),e.timeout=r,e.setRequestHeader("Content-Type",i),e.onerror=function(){a&&a(null)},e.onreadystatechange=function(){if(e.readyState===c.states.complete&&a){var t=c.parseJSON(e.responseText);a(t)}},s&&(e.ontimeout=s),e.send(o)}},{key:"parseJSON",value:function(e){if(!e||""===e)return null;try{return JSON.parse(e)}catch(t){return console&&console.log("failed to parse JSON response",e),null}}},{key:"serialize",value:function(e,t){var n=[];for(var i in e)if(e.hasOwnProperty(i)){var r=t?"".concat(t,"[").concat(i,"]"):i,s=e[i];"object"===o(s)?n.push(this.serialize(s,r)):n.push(encodeURIComponent(r)+"="+encodeURIComponent(s))}return n.join("&")}},{key:"appendParams",value:function(e,t){if(0===Object.keys(t).length)return e;var n=e.match(/\?/)?"&":"?";return"".concat(e).concat(n).concat(this.serialize(t))}}]),e}();T.states={complete:4};var w=function(){function e(t){var n=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};s(this,e);var o=i.events||{state:"presence_state",diff:"presence_diff"};this.state={},this.pendingDiffs=[],this.channel=t,this.joinRef=null,this.caller={onJoin:function(){},onLeave:function(){},onSync:function(){}},this.channel.on(o.state,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.joinRef=n.channel.joinRef(),n.state=e.syncState(n.state,t,o,r),n.pendingDiffs.forEach(function(t){n.state=e.syncDiff(n.state,t,o,r)}),n.pendingDiffs=[],s()}),this.channel.on(o.diff,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.inPendingSyncState()?n.pendingDiffs.push(t):(n.state=e.syncDiff(n.state,t,o,r),s())})}return c(e,[{key:"onJoin",value:function(e){this.caller.onJoin=e}},{key:"onLeave",value:function(e){this.caller.onLeave=e}},{key:"onSync",value:function(e){this.caller.onSync=e}},{key:"list",value:function(t){return e.list(this.state,t)}},{key:"inPendingSyncState",value:function(){return!this.joinRef||this.joinRef!==this.channel.joinRef()}}],[{key:"syncState",value:function(e,t,n,i){var o=this,r=this.clone(e),s={},a={};return this.map(r,function(e,n){t[e]||(a[e]=n)}),this.map(t,function(e,t){var n=r[e];if(n){var i=t.metas.map(function(e){return e.phx_ref}),c=n.metas.map(function(e){return e.phx_ref}),u=t.metas.filter(function(e){return c.indexOf(e.phx_ref)<0}),h=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0});u.length>0&&(s[e]=t,s[e].metas=u),h.length>0&&(a[e]=o.clone(n),a[e].metas=h)}else s[e]=t}),this.syncDiff(r,{joins:s,leaves:a},n,i)}},{key:"syncDiff",value:function(e,t,n,o){var r=t.joins,s=t.leaves,a=this.clone(e);return n||(n=function(){}),o||(o=function(){}),this.map(r,function(e,t){var o=a[e];if(a[e]=t,o){var r,s=a[e].metas.map(function(e){return e.phx_ref}),c=o.metas.filter(function(e){return s.indexOf(e.phx_ref)<0});(r=a[e].metas).unshift.apply(r,i(c))}n(e,o,t)}),this.map(s,function(e,t){var n=a[e];if(n){var i=t.metas.map(function(e){return e.phx_ref});n.metas=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0}),o(e,n,t),0===n.metas.length&&delete a[e]}}),a}},{key:"list",value:function(e,t){return t||(t=function(e,t){return t}),this.map(e,function(e,n){return t(e,n)})}},{key:"map",value:function(e,t){return Object.getOwnPropertyNames(e).map(function(n){return t(n,e[n])})}},{key:"clone",value:function(e){return JSON.parse(JSON.stringify(e))}}]),e}(),E=function(){function e(t,n){s(this,e),this.callback=t,this.timerCalc=n,this.timer=null,this.tries=0}return c(e,[{key:"reset",value:function(){this.tries=0,clearTimeout(this.timer)}},{key:"scheduleTimeout",value:function(){var e=this;clearTimeout(this.timer),this.timer=setTimeout(function(){e.tries=e.tries+1,e.callback()},this.timerCalc(this.tries+1))}}]),e}()}])});
\ No newline at end of file


Test patch:
diff --git a/assets/test/channel_test.js b/assets/test/channel_test.js
index b90ac14b5..959e6f57f 100644
--- a/assets/test/channel_test.js
+++ b/assets/test/channel_test.js
@@ -2,7 +2,6 @@ import assert from "assert"
 
 import jsdom from "jsdom"
 import sinon from "sinon"
-import {WebSocket, Server as WebSocketServer} from "mock-socket"
 import {encode, decode} from "./serializer"
 import {Channel, Socket} from "../js/phoenix"
 
@@ -11,1006 +10,1040 @@ let channel, socket
 const defaultRef = 1
 const defaultTimeout = 10000
 
-describe("constructor", () => {
-  beforeEach(() => {
-    socket = { timeout: 1234 }
-  })
-
-  it("sets defaults", () => {
-    channel = new Channel("topic", { one: "two" }, socket)
-
-    assert.equal(channel.state, "closed")
-    assert.equal(channel.topic, "topic")
-    assert.deepEqual(channel.params(), { one: "two" })
-    assert.deepEqual(channel.socket, socket)
-    assert.equal(channel.timeout, 1234)
-    assert.equal(channel.joinedOnce, false)
-    assert.ok(channel.joinPush)
-    assert.deepEqual(channel.pushBuffer, [])
-  })
+class WSMock {
+  constructor(){}
+  close(){}
+  send(){}
+}
 
-  it("sets up joinPush objec with literal params", () => {
-    channel = new Channel("topic", { one: "two" }, socket)
-    const joinPush = channel.joinPush
+describe("with transport", () => {
+  let mockServer
 
-    assert.deepEqual(joinPush.channel, channel)
-    assert.deepEqual(joinPush.payload(), { one: "two" })
-    assert.equal(joinPush.event, "phx_join")
-    assert.equal(joinPush.timeout, 1234)
+  before(() => {
+    window.WebSocket = WSMock
   })
 
-  it("sets up joinPush objec with closure params", () => {
-    channel = new Channel("topic", function(){ return({one: "two"}) }, socket)
-    const joinPush = channel.joinPush
-
-    assert.deepEqual(joinPush.channel, channel)
-    assert.deepEqual(joinPush.payload(), { one: "two" })
-    assert.equal(joinPush.event, "phx_join")
-    assert.equal(joinPush.timeout, 1234)
+  after((done) => {
+    window.WebSocket = null
+    done()
   })
 
-})
-
-describe("updating join params", () => {
-  beforeEach(() => {
-    socket = { timeout: 1234 }
-  })
-
-  it("can update the join params", () => {
-    let counter = 0
-    let params = function(){ return({value: counter}) }
-
-    channel = new Channel("topic", params, socket)
-    const joinPush = channel.joinPush
-
-    assert.deepEqual(joinPush.channel, channel)
-    assert.deepEqual(joinPush.payload(), { value: 0 })
-    assert.equal(joinPush.event, "phx_join")
-    assert.equal(joinPush.timeout, 1234)
+  describe("constructor", () => {
+    beforeEach(() => {
+      socket = new Socket("/", {timeout: 1234})
+    })
 
-    counter++
+    it("sets defaults", () => {
+      channel = new Channel("topic", { one: "two" }, socket)
+
+      assert.equal(channel.state, "closed")
+      assert.equal(channel.topic, "topic")
+      assert.deepEqual(channel.params(), { one: "two" })
+      assert.deepEqual(channel.socket, socket)
+      assert.equal(channel.timeout, 1234)
+      assert.equal(channel.joinedOnce, false)
+      assert.ok(channel.joinPush)
+      assert.deepEqual(channel.pushBuffer, [])
+    })
 
-    assert.deepEqual(joinPush.channel, channel)
-    assert.deepEqual(joinPush.payload(), { value: 1 })
-    assert.deepEqual(channel.params(), { value: 1 })
-    assert.equal(joinPush.event, "phx_join")
-    assert.equal(joinPush.timeout, 1234)
-  });
-});
+    it("sets up joinPush objec with literal params", () => {
+      channel = new Channel("topic", { one: "two" }, socket)
+      const joinPush = channel.joinPush
 
-describe("join", () => {
-  beforeEach(() => {
-    socket = new Socket("/socket", { timeout: defaultTimeout })
+      assert.deepEqual(joinPush.channel, channel)
+      assert.deepEqual(joinPush.payload(), { one: "two" })
+      assert.equal(joinPush.event, "phx_join")
+      assert.equal(joinPush.timeout, 1234)
+    })
 
-    channel = socket.channel("topic", { one: "two" })
-  })
+    it("sets up joinPush objec with closure params", () => {
+      channel = new Channel("topic", function(){ return({one: "two"}) }, socket)
+      const joinPush = channel.joinPush
 
-  it("sets state to joining", () => {
-    channel.join()
+      assert.deepEqual(joinPush.channel, channel)
+      assert.deepEqual(joinPush.payload(), { one: "two" })
+      assert.equal(joinPush.event, "phx_join")
+      assert.equal(joinPush.timeout, 1234)
+    })
 
-    assert.equal(channel.state, "joining")
   })
 
-  it("sets joinedOnce to true", () => {
-    assert.ok(!channel.joinedOnce)
+  describe("updating join params", () => {
+    beforeEach(() => {
+      socket = {timeout: 1234, onError: function(){}, onOpen: function(){}}
+    })
 
-    channel.join()
+    it("can update the join params", () => {
+      let counter = 0
+      let params = function(){ return({value: counter}) }
 
-    assert.ok(channel.joinedOnce)
-  })
-
-  it("throws if attempting to join multiple times", () => {
-    channel.join()
+      channel = new Channel("topic", params, socket)
+      const joinPush = channel.joinPush
 
-    assert.throws(() => channel.join(), /^Error: tried to join multiple times/)
-  })
+      assert.deepEqual(joinPush.channel, channel)
+      assert.deepEqual(joinPush.payload(), { value: 0 })
+      assert.equal(joinPush.event, "phx_join")
+      assert.equal(joinPush.timeout, 1234)
 
-  it("triggers socket push with channel params", () => {
-    sinon.stub(socket, "makeRef", () => defaultRef)
-    const spy = sinon.spy(socket, "push")
+      counter++
 
-    channel.join()
+      assert.deepEqual(joinPush.channel, channel)
+      assert.deepEqual(joinPush.payload(), { value: 1 })
+      assert.deepEqual(channel.params(), { value: 1 })
+      assert.equal(joinPush.event, "phx_join")
+      assert.equal(joinPush.timeout, 1234)
+    });
+  });
 
-    assert.ok(spy.calledOnce)
-    assert.ok(spy.calledWith({
-      topic: "topic",
-      event: "phx_join",
-      payload: { one: "two" },
-      ref: defaultRef,
-      join_ref: channel.joinRef()
-    }))
-  })
+  describe("join", () => {
+    beforeEach(() => {
+      socket = new Socket("/socket", { timeout: defaultTimeout })
 
-  it("can set timeout on joinPush", () => {
-    const newTimeout = 2000
-    const joinPush = channel.joinPush
+      channel = socket.channel("topic", { one: "two" })
+    })
 
-    assert.equal(joinPush.timeout, defaultTimeout)
+    it("sets state to joining", () => {
+      channel.join()
 
-    channel.join(newTimeout)
+      assert.equal(channel.state, "joining")
+    })
 
-    assert.equal(joinPush.timeout, newTimeout)
-  })
+    it("sets joinedOnce to true", () => {
+      assert.ok(!channel.joinedOnce)
 
-  describe("timeout behavior", () => {
-    let clock, joinPush
-
-    const helpers = {
-      receiveSocketOpen() {
-        sinon.stub(socket, "isConnected", () => true)
-        socket.onConnOpen()
-      }
-    }
+      channel.join()
 
-    beforeEach(() => {
-      clock = sinon.useFakeTimers()
-      joinPush = channel.joinPush
+      assert.ok(channel.joinedOnce)
     })
 
-    afterEach(() => {
-      clock.restore()
-    })
+    it("throws if attempting to join multiple times", () => {
+      channel.join()
 
-    it("succeeds before timeout", () => {
-      const spy = sinon.stub(socket, "push")
-      const timeout = joinPush.timeout
+      assert.throws(() => channel.join(), /^Error: tried to join multiple times/)
+    })
 
-      socket.connect()
-      helpers.receiveSocketOpen()
+    it("triggers socket push with channel params", () => {
+      sinon.stub(socket, "makeRef", () => defaultRef)
+      const spy = sinon.spy(socket, "push")
 
       channel.join()
-      assert.equal(spy.callCount, 1)
 
-      clock.tick(timeout / 2)
+      assert.ok(spy.calledOnce)
+      assert.ok(spy.calledWith({
+        topic: "topic",
+        event: "phx_join",
+        payload: { one: "two" },
+        ref: defaultRef,
+        join_ref: channel.joinRef()
+      }))
+    })
 
-      joinPush.trigger("ok", {})
+    it("can set timeout on joinPush", () => {
+      const newTimeout = 2000
+      const joinPush = channel.joinPush
 
-      assert.equal(channel.state, "joined")
+      assert.equal(joinPush.timeout, defaultTimeout)
 
-      clock.tick(timeout)
-      assert.equal(spy.callCount, 1)
+      channel.join(newTimeout)
+
+      assert.equal(joinPush.timeout, newTimeout)
     })
 
-    it("retries with backoff after timeout", () => {
-      const spy = sinon.stub(socket, "push")
-      const timeout = joinPush.timeout
-      let callCount = 1
+    describe("timeout behavior", () => {
+      let clock, joinPush
 
-      socket.connect()
-      helpers.receiveSocketOpen()
+      const helpers = {
+        receiveSocketOpen() {
+          sinon.stub(socket, "isConnected", () => true)
+          socket.onConnOpen()
+        }
+      }
 
-      channel.join()
-      assert.equal(spy.callCount, callCount)
+      beforeEach(() => {
+        clock = sinon.useFakeTimers()
+        joinPush = channel.joinPush
+      })
 
-      clock.tick(timeout)
-      assert.equal(spy.callCount, ++callCount) // leave pushed to server
+      afterEach(() => {
+        clock.restore()
+      })
 
-      clock.tick(1000)
-      assert.equal(spy.callCount, ++callCount)
+      it("succeeds before timeout", () => {
+        const spy = sinon.stub(socket, "push")
+        const timeout = joinPush.timeout
 
-      clock.tick(2000)
-      assert.equal(spy.callCount, ++callCount)
+        socket.connect()
+        helpers.receiveSocketOpen()
 
-      clock.tick(5000)
-      assert.equal(spy.callCount, ++callCount)
+        channel.join()
+        assert.equal(spy.callCount, 1)
 
-      clock.tick(10000)
-      assert.equal(spy.callCount, ++callCount)
 
-      console.log("test: joinPush.trigger ok")
-      joinPush.trigger("ok", {})
-      assert.equal(channel.state, "joined")
+        assert.equal(channel.timeout, 10000)
+        clock.tick(100)
 
-      clock.tick(10000)
-      assert.equal(spy.callCount, callCount)
-      assert.equal(channel.state, "joined")
-    })
+        joinPush.trigger("ok", {})
 
-    it("with socket and join delay", () => {
-      const spy = sinon.stub(socket, "push")
-      const clock = sinon.useFakeTimers()
-      const joinPush = channel.joinPush
+        assert.equal(channel.state, "joined")
 
-      channel.join()
-      assert.equal(spy.callCount, 1)
+        clock.tick(timeout)
+        assert.equal(spy.callCount, 1)
+      })
 
-      // open socket after delay
-      clock.tick(9000)
+      it("retries with backoff after timeout", () => {
+        const spy = sinon.stub(socket, "push")
+        const timeoutSpy = sinon.spy()
+        const timeout = joinPush.timeout
 
-      assert.equal(spy.callCount, 1)
+        socket.connect()
+        helpers.receiveSocketOpen()
 
-      // join request returns between timeouts
-      clock.tick(1000)
-      socket.connect()
-      helpers.receiveSocketOpen()
-      joinPush.trigger("ok", {})
+        channel.join().receive("timeout", timeoutSpy)
 
-      assert.equal(channel.state, "errored")
+        assert.equal(spy.callCount, 1)
+        assert.equal(timeoutSpy.callCount, 0)
 
-      // join request succeeds after delay
-      clock.tick(1000)
+        clock.tick(timeout)
+        assert.equal(spy.callCount, 2) // leave pushed to server
+        assert.equal(timeoutSpy.callCount, 1)
 
-      assert.equal(channel.state, "joining")
+        clock.tick(timeout + 1000)
+        assert.equal(spy.callCount, 4) // leave + rejoin
+        assert.equal(timeoutSpy.callCount, 2)
 
-      joinPush.trigger("ok", {})
+        clock.tick(10000)
+        joinPush.trigger("ok", {})
+        assert.equal(spy.callCount, 6)
+        assert.equal(channel.state, "joined")
+      })
 
-      assert.equal(channel.state, "joined")
-      assert.equal(spy.callCount, 3) // leave pushed to server
-    })
+      it("with socket and join delay", () => {
+        const spy = sinon.stub(socket, "push")
+        const clock = sinon.useFakeTimers()
+        const joinPush = channel.joinPush
 
-    it("with socket delay only", () => {
-      const spy = sinon.stub(socket, "push")
-      const clock = sinon.useFakeTimers()
-      const joinPush = channel.joinPush
+        channel.join()
+        assert.equal(spy.callCount, 1)
 
-      channel.join()
+        // open socket after delay
+        clock.tick(9000)
 
-      // connect socket after delay
-      clock.tick(6000)
-      socket.connect()
+        assert.equal(spy.callCount, 1)
 
-      // open socket after delay
-      clock.tick(5000)
-      helpers.receiveSocketOpen()
-      joinPush.trigger("ok", {})
+        // join request returns between timeouts
+        clock.tick(1000)
+        socket.connect()
 
-      clock.tick(2000)
-      assert.equal(channel.state, "joining")
+        assert.equal(channel.state, "errored")
 
-      joinPush.trigger("ok", {})
-      assert.equal(channel.state, "joined")
-    })
-  })
-})
+        helpers.receiveSocketOpen()
+        joinPush.trigger("ok", {})
 
-describe("joinPush", () => {
-  let joinPush, clock, response
+        // join request succeeds after delay
+        clock.tick(1000)
 
-  const helpers = {
-    receiveOk() {
-      clock.tick(joinPush.timeout / 2) // before timeout
-      return joinPush.trigger("ok", response)
-    },
+        assert.equal(channel.state, "joined")
 
-    receiveTimeout() {
-      clock.tick(joinPush.timeout * 2) // after timeout
-    },
+        assert.equal(spy.callCount, 3) // leave pushed to server
+      })
 
-    receiveError() {
-      clock.tick(joinPush.timeout / 2) // before timeout
-      return joinPush.trigger("error", response)
-    },
+      it("with socket delay only", () => {
+        const spy = sinon.stub(socket, "push")
+        const clock = sinon.useFakeTimers()
+        const joinPush = channel.joinPush
 
-    getBindings(event) {
-      return channel.bindings.filter(bind => bind.event === event )
-    }
-  }
+        channel.join()
 
-  beforeEach(() => {
-    clock = sinon.useFakeTimers()
+        assert.equal(channel.state, "joining")
 
-    socket = new Socket("/socket", { timeout: defaultTimeout })
+        // connect socket after delay
+        clock.tick(6000)
+        socket.connect()
 
-    channel = socket.channel("topic", { one: "two" })
-    joinPush = channel.joinPush
+        // open socket after delay
+        clock.tick(5000)
+        helpers.receiveSocketOpen()
+        joinPush.trigger("ok", {})
 
-    channel.join()
+        joinPush.trigger("ok", {})
+        assert.equal(channel.state, "joined")
+      })
+    })
   })
 
-  afterEach(() => {
-    clock.restore()
-  })
+  describe("joinPush", () => {
+    let joinPush, clock, response
+
+    const helpers = {
+      receiveOk() {
+        clock.tick(joinPush.timeout / 2) // before timeout
+        return joinPush.channel.trigger("phx_reply", {status: "ok", response: response}, joinPush.ref, joinPush.ref)
+        // return joinPush.trigger("ok", response)
+      },
+
+      receiveTimeout() {
+        clock.tick(joinPush.timeout * 2) // after timeout
+      },
+
+      receiveError() {
+        clock.tick(joinPush.timeout / 2) // before timeout
+        return joinPush.trigger("error", response)
+      },
+
+      getBindings(event) {
+        return channel.bindings.filter(bind => bind.event === event )
+      }
+    }
 
-  describe("receives 'ok'", () => {
     beforeEach(() => {
-      response = { chan: "reply" }
-    })
+      clock = sinon.useFakeTimers()
 
-    it("sets channel state to joined", () => {
-      assert.notEqual(channel.state, "joined")
+      socket = new Socket("/socket", { timeout: defaultTimeout })
+      sinon.stub(socket, "isConnected", () => true)
+      sinon.stub(socket, "push", () => true)
 
-      helpers.receiveOk()
+      channel = socket.channel("topic", { one: "two" })
+      joinPush = channel.joinPush
 
-      assert.equal(channel.state, "joined")
+      channel.join()
     })
 
-    it("triggers receive('ok') callback after ok response", () => {
-      const spyOk = sinon.spy()
+    afterEach(() => {
+      clock.restore()
+    })
 
-      joinPush.receive("ok", spyOk)
+    describe("receives 'ok'", () => {
+      beforeEach(() => {
+        response = { chan: "reply" }
+      })
 
-      helpers.receiveOk()
+      it("sets channel state to joined", () => {
+        assert.notEqual(channel.state, "joined")
 
-      assert.ok(spyOk.calledOnce)
-    })
+        helpers.receiveOk()
 
-    it("triggers receive('ok') callback if ok response already received", () => {
-      const spyOk = sinon.spy()
+        assert.equal(channel.state, "joined")
+      })
 
-      helpers.receiveOk()
+      it("triggers receive('ok') callback after ok response", () => {
+        const spyOk = sinon.spy()
 
-      joinPush.receive("ok", spyOk)
+        joinPush.receive("ok", spyOk)
 
-      assert.ok(spyOk.calledOnce)
-    })
+        helpers.receiveOk()
 
-    it("does not trigger other receive callbacks after ok response", () => {
-      const spyError = sinon.spy()
-      const spyTimeout = sinon.spy()
+        assert.ok(spyOk.calledOnce)
+      })
 
-      joinPush
-        .receive("error", spyError)
-        .receive("timeout", spyTimeout)
+      it("triggers receive('ok') callback if ok response already received", () => {
+        const spyOk = sinon.spy()
 
-      helpers.receiveOk()
-      clock.tick(channel.timeout * 2) // attempt timeout
+        helpers.receiveOk()
 
-      assert.ok(!spyError.called)
-      assert.ok(!spyTimeout.called)
-    })
+        joinPush.receive("ok", spyOk)
 
-    it("clears timeoutTimer", () => {
-      assert.ok(joinPush.timeoutTimer)
+        assert.ok(spyOk.calledOnce)
+      })
 
-      helpers.receiveOk()
+      it("does not trigger other receive callbacks after ok response", () => {
+        const spyError = sinon.spy()
+        const spyTimeout = sinon.spy()
 
-      assert.equal(joinPush.timeoutTimer, null)
-    })
+        joinPush
+          .receive("error", spyError)
+          .receive("timeout", spyTimeout)
 
-    it("sets receivedResp", () => {
-      assert.equal(joinPush.receivedResp, null)
+        helpers.receiveOk()
+        clock.tick(channel.timeout * 2) // attempt timeout
 
-      helpers.receiveOk()
+        assert.ok(!spyError.called)
+        assert.ok(!spyTimeout.called)
+      })
 
-      assert.deepEqual(joinPush.receivedResp, { status: "ok", response })
-    })
+      it("clears timeoutTimer", () => {
+        assert.ok(joinPush.timeoutTimer)
 
-    it("removes channel bindings", () => {
-      let bindings = helpers.getBindings("chan_reply_1")
-      assert.equal(bindings.length, 1)
+        helpers.receiveOk()
 
-      helpers.receiveOk()
+        assert.equal(joinPush.timeoutTimer, null)
+      })
 
-      bindings = helpers.getBindings("chan_reply_1")
-      assert.equal(bindings.length, 0)
-    })
+      it("sets receivedResp", () => {
+        assert.equal(joinPush.receivedResp, null)
 
-    it("sets channel state to joined", () => {
-      helpers.receiveOk()
+        helpers.receiveOk()
 
-      assert.equal(channel.state, "joined")
-    })
+        assert.deepEqual(joinPush.receivedResp, { status: "ok", response })
+      })
 
-    it("resets channel rejoinTimer", () => {
-      assert.ok(channel.rejoinTimer)
+      it("removes channel bindings", () => {
+        let bindings = helpers.getBindings("chan_reply_1")
+        assert.equal(bindings.length, 1)
 
-      const spy = sinon.spy(channel.rejoinTimer, "reset")
+        helpers.receiveOk()
 
-      helpers.receiveOk()
+        bindings = helpers.getBindings("chan_reply_1")
+        assert.equal(bindings.length, 0)
+      })
 
-      assert.ok(spy.calledOnce)
-    })
+      it("sets channel state to joined", () => {
+        helpers.receiveOk()
 
-    it("sends and empties channel's buffered pushEvents", () => {
-      const pushEvent = { send() {} }
-      const spy = sinon.spy(pushEvent, "send")
+        assert.equal(channel.state, "joined")
+      })
 
-      channel.pushBuffer.push(pushEvent)
+      it("resets channel rejoinTimer", () => {
+        assert.ok(channel.rejoinTimer)
 
-      helpers.receiveOk()
+        const spy = sinon.spy(channel.rejoinTimer, "reset")
 
-      assert.ok(spy.calledOnce)
-      assert.equal(channel.pushBuffer.length, 0)
-    })
-  })
+        helpers.receiveOk()
 
-  describe("receives 'timeout'", () => {
-    it("sets channel state to errored", () => {
-      helpers.receiveTimeout()
+        assert.ok(spy.calledOnce)
+      })
 
-      assert.equal(channel.state, "errored")
+      it("sends and empties channel's buffered pushEvents", done => {
+        const pushEvent = { send() {} }
+        const spy = sinon.spy(pushEvent, "send")
+
+        channel.pushBuffer.push(pushEvent)
+
+        assert.equal(channel.state, "joining")
+        joinPush.receive("ok", () => {
+          assert.equal(spy.callCount, 1)
+          assert.equal(channel.pushBuffer.length, 0)
+          done()
+        })
+        helpers.receiveOk()
+      })
     })
 
-    it("triggers receive('timeout') callback after ok response", () => {
-      const spyTimeout = sinon.spy()
+    describe("receives 'timeout'", () => {
+      it("sets channel state to errored", done => {
+        joinPush.receive("timeout", () => {
+          assert.equal(channel.state, "errored")
+          done()
+        })
 
-      joinPush.receive("timeout", spyTimeout)
+        helpers.receiveTimeout()
+      })
 
-      helpers.receiveTimeout()
+      it("triggers receive('timeout') callback after ok response", () => {
+        const spyTimeout = sinon.spy()
 
-      assert.ok(spyTimeout.calledOnce)
-    })
+        joinPush.receive("timeout", spyTimeout)
 
-    it("does not trigger other receive callbacks after timeout response", () => {
-      const spyOk = sinon.spy()
-      const spyError = sinon.spy()
+        helpers.receiveTimeout()
 
-      joinPush
-        .receive("ok", spyOk)
-        .receive("error", spyError)
+        assert.ok(spyTimeout.calledOnce)
+      })
 
-      helpers.receiveTimeout()
-      helpers.receiveOk()
+      it("does not trigger other receive callbacks after timeout response", done => {
+        const spyOk = sinon.spy()
+        const spyError = sinon.spy()
+        sinon.stub(channel.rejoinTimer, "scheduleTimeout", () => true)
 
-      assert.ok(!spyOk.called)
-      assert.ok(!spyError.called)
-    })
+        channel.test = true
+        joinPush
+          .receive("ok", spyOk)
+          .receive("error", spyError)
+          .receive("timeout", () => {
+            assert.ok(!spyOk.called)
+            assert.ok(!spyError.called)
+            done()
+          })
 
-    it("schedules rejoinTimer timeout", () => {
-      assert.ok(channel.rejoinTimer)
+        helpers.receiveTimeout()
+        helpers.receiveOk()
+      })
 
-      const spy = sinon.spy(channel.rejoinTimer, "scheduleTimeout")
+      it("schedules rejoinTimer timeout", () => {
+        assert.ok(channel.rejoinTimer)
 
-      helpers.receiveTimeout()
+        const spy = sinon.spy(channel.rejoinTimer, "scheduleTimeout")
 
-      assert.ok(spy.called) // TODO why called multiple times?
-    })
-  })
+        helpers.receiveTimeout()
 
-  describe("receives 'error'", () => {
-    beforeEach(() => {
-      response = { chan: "fail" }
+        assert.ok(spy.called) // TODO why called multiple times?
+      })
     })
 
-    it("triggers receive('error') callback after error response", () => {
-      const spyError = sinon.spy()
+    describe("receives 'error'", () => {
+      beforeEach(() => {
+        response = { chan: "fail" }
+      })
 
-      joinPush.receive("error", spyError)
+      it("triggers receive('error') callback after error response", () => {
+        const spyError = sinon.spy()
 
-      helpers.receiveError()
+        assert.equal(channel.state, "joining")
+        joinPush.receive("error", spyError)
 
-      assert.ok(spyError.calledOnce)
-    })
+        helpers.receiveError()
+        joinPush.trigger("error", {})
 
-    it("triggers receive('error') callback if error response already received", () => {
-      const spyError = sinon.spy()
+        assert.equal(spyError.callCount, 1)
+      })
 
-      helpers.receiveError()
+      it("triggers receive('error') callback if error response already received", () => {
+        const spyError = sinon.spy()
 
-      joinPush.receive("error", spyError)
+        helpers.receiveError()
 
-      assert.ok(spyError.calledOnce)
-    })
+        joinPush.receive("error", spyError)
 
-    it("does not trigger other receive callbacks after ok response", () => {
-      const spyOk = sinon.spy()
-      const spyTimeout = sinon.spy()
+        assert.ok(spyError.calledOnce)
+      })
 
-      joinPush
-        .receive("ok", spyOk)
-        .receive("timeout", spyTimeout)
+      it("does not trigger other receive callbacks after error response", () => {
+        const spyOk = sinon.spy()
+        const spyError = sinon.spy()
+        const spyTimeout = sinon.spy()
 
-      helpers.receiveError()
-      clock.tick(channel.timeout * 2) // attempt timeout
+        joinPush
+          .receive("ok", spyOk)
+          .receive("error", () => {
+            spyError()
+            channel.leave()
+          })
+          .receive("timeout", spyTimeout)
 
-      assert.ok(!spyOk.called)
-      assert.ok(!spyTimeout.called)
-    })
+        helpers.receiveError()
+        clock.tick(channel.timeout * 2) // attempt timeout
 
-    it("clears timeoutTimer", () => {
-      assert.ok(joinPush.timeoutTimer)
+        assert.ok(spyError.calledOnce)
+        assert.ok(!spyOk.called)
+        assert.ok(!spyTimeout.called)
+      })
 
-      helpers.receiveError()
+      it("clears timeoutTimer", () => {
+        assert.ok(joinPush.timeoutTimer)
 
-      assert.equal(joinPush.timeoutTimer, null)
-    })
+        helpers.receiveError()
 
-    it("sets receivedResp", () => {
-      assert.equal(joinPush.receivedResp, null)
+        assert.equal(joinPush.timeoutTimer, null)
+      })
 
-      helpers.receiveError()
+      it("sets receivedResp with error trigger after binding", done => {
+        assert.equal(joinPush.receivedResp, null)
 
-      assert.deepEqual(joinPush.receivedResp, { status: "error", response })
-    })
+        joinPush.receive("error", resp => {
+          assert.deepEqual(resp, response)
+          done()
+        })
 
-    it("removes channel bindings", () => {
-      let bindings = helpers.getBindings("chan_reply_1")
-      assert.equal(bindings.length, 1)
+        helpers.receiveError()
+      })
 
-      helpers.receiveError()
+      it("sets receivedResp with error trigger before binding", done => {
+        assert.equal(joinPush.receivedResp, null)
 
-      bindings = helpers.getBindings("chan_reply_1")
-      assert.equal(bindings.length, 0)
-    })
+        helpers.receiveError()
+        joinPush.receive("error", resp => {
+          assert.deepEqual(resp, response)
+          done()
+        })
+      })
 
-    it("does not set channel state to joined", () => {
-      helpers.receiveError()
+      it("does not set channel state to joined", () => {
+        helpers.receiveError()
 
-      assert.equal(channel.state, "joining")
-    })
+        assert.equal(channel.state, "errored")
+      })
 
-    it("does not trigger channel's buffered pushEvents", () => {
-      const pushEvent = { send: () => {} }
-      const spy = sinon.spy(pushEvent, "send")
+      it("does not trigger channel's buffered pushEvents", () => {
+        const pushEvent = { send: () => {} }
+        const spy = sinon.spy(pushEvent, "send")
 
-      channel.pushBuffer.push(pushEvent)
+        channel.pushBuffer.push(pushEvent)
 
-      helpers.receiveError()
+        helpers.receiveError()
 
-      assert.ok(!spy.called)
-      assert.equal(channel.pushBuffer.length, 1)
+        assert.ok(!spy.called)
+        assert.equal(channel.pushBuffer.length, 1)
+      })
     })
   })
-})
-
-describe("onError", () => {
-  let clock, joinPush
 
-  beforeEach(() => {
-    clock = sinon.useFakeTimers()
+  describe("onError", () => {
+    let clock, joinPush
 
-    socket = new Socket("/socket", { timeout: defaultTimeout })
-    sinon.stub(socket, "isConnected", () => true)
-    sinon.stub(socket, "push", () => true)
+    beforeEach(() => {
+      clock = sinon.useFakeTimers()
 
-    channel = socket.channel("topic", { one: "two" })
+      socket = new Socket("/socket", { timeout: defaultTimeout })
+      sinon.stub(socket, "isConnected", () => true)
+      sinon.stub(socket, "push", () => true)
 
-    joinPush = channel.joinPush
+      channel = socket.channel("topic", { one: "two" })
 
-    channel.join()
-  })
+      joinPush = channel.joinPush
 
-  afterEach(() => {
-    clock.restore()
-  })
+      channel.join()
+      joinPush.trigger("ok", {})
+    })
 
-  it("sets state to 'errored'", () => {
-    assert.notEqual(channel.state, "errored")
+    afterEach(() => {
+      clock.restore()
+    })
 
-    channel.trigger("phx_error")
+    it("sets state to 'errored'", () => {
+      assert.notEqual(channel.state, "errored")
 
-    assert.equal(channel.state, "errored")
-  })
+      channel.trigger("phx_error")
 
-  it("tries to rejoin with backoff", () => {
-    const spy = sinon.stub(joinPush, "send")
+      assert.equal(channel.state, "errored")
+    })
 
-    assert.equal(spy.callCount, 0)
+    it("does not trigger redudant errors during backoff", () => {
+      const spy = sinon.stub(joinPush, "send")
 
-    channel.trigger("phx_error")
+      assert.equal(spy.callCount, 0)
 
-    clock.tick(1000)
-    assert.equal(spy.callCount, 1)
+      channel.trigger("phx_error")
 
-    clock.tick(2000)
-    assert.equal(spy.callCount, 2)
+      clock.tick(1000)
+      assert.equal(spy.callCount, 1)
 
-    clock.tick(5000)
-    assert.equal(spy.callCount, 3)
+      joinPush.trigger("error", {})
 
-    clock.tick(10000)
-    assert.equal(spy.callCount, 4)
-  })
+      clock.tick(10000)
+      assert.equal(spy.callCount, 1)
+    })
 
-  it("does not rejoin if channel leaving", () => {
-    channel.state = "leaving"
+    it("does not rejoin if channel leaving", () => {
+      channel.state = "leaving"
 
-    const spy = sinon.stub(joinPush, "send")
+      const spy = sinon.stub(joinPush, "send")
 
-    channel.trigger("phx_error")
+      socket.onConnError({})
 
-    clock.tick(1000)
-    assert.equal(spy.callCount, 0)
+      clock.tick(1000)
+      assert.equal(spy.callCount, 0)
 
-    clock.tick(2000)
-    assert.equal(spy.callCount, 0)
+      clock.tick(2000)
+      assert.equal(spy.callCount, 0)
 
-    assert.equal(channel.state, "leaving")
-  })
+      assert.equal(channel.state, "leaving")
+    })
 
-  it("does not rejoin if channel closed", () => {
-    channel.state = "closed"
+    it("does not rejoin if channel closed", () => {
+      channel.state = "closed"
 
-    const spy = sinon.stub(joinPush, "send")
+      const spy = sinon.stub(joinPush, "send")
 
-    channel.trigger("phx_error")
+      socket.onConnError({})
 
-    clock.tick(1000)
-    assert.equal(spy.callCount, 0)
+      clock.tick(1000)
+      assert.equal(spy.callCount, 0)
 
-    clock.tick(2000)
-    assert.equal(spy.callCount, 0)
+      clock.tick(2000)
+      assert.equal(spy.callCount, 0)
 
-    assert.equal(channel.state, "closed")
-  })
+      assert.equal(channel.state, "closed")
+    })
 
-  it("triggers additional callbacks", () => {
-    const spy = sinon.spy()
-    channel.onError(spy)
+    it("triggers additional callbacks after join", () => {
+      const spy = sinon.spy()
+      channel.onError(spy)
+      joinPush.trigger("ok", {})
 
-    assert.equal(spy.callCount, 0)
+      assert.equal(channel.state, "joined")
+      assert.equal(spy.callCount, 0)
 
-    channel.trigger("phx_error")
+      channel.trigger("phx_error")
 
-    assert.equal(spy.callCount, 1)
+      assert.equal(spy.callCount, 1)
+    })
   })
-})
 
-describe("onClose", () => {
-  let clock, joinPush
+  describe("onClose", () => {
+    let clock, joinPush
 
-  beforeEach(() => {
-    clock = sinon.useFakeTimers()
+    beforeEach(() => {
+      clock = sinon.useFakeTimers()
 
-    socket = new Socket("/socket", { timeout: defaultTimeout })
-    sinon.stub(socket, "isConnected", () => true)
-    sinon.stub(socket, "push", () => true)
+      socket = new Socket("/socket", { timeout: defaultTimeout })
+      sinon.stub(socket, "isConnected", () => true)
+      sinon.stub(socket, "push", () => true)
 
-    channel = socket.channel("topic", { one: "two" })
+      channel = socket.channel("topic", { one: "two" })
 
-    joinPush = channel.joinPush
+      joinPush = channel.joinPush
 
-    channel.join()
-  })
+      channel.join()
+    })
 
-  afterEach(() => {
-    clock.restore()
-  })
+    afterEach(() => {
+      clock.restore()
+    })
 
-  it("sets state to 'closed'", () => {
-    assert.notEqual(channel.state, "closed")
+    it("sets state to 'closed'", () => {
+      assert.notEqual(channel.state, "closed")
 
-    channel.trigger("phx_close")
+      channel.trigger("phx_close")
 
-    assert.equal(channel.state, "closed")
-  })
+      assert.equal(channel.state, "closed")
+    })
 
-  it("does not rejoin", () => {
-    const spy = sinon.stub(joinPush, "send")
+    it("does not rejoin", () => {
+      const spy = sinon.stub(joinPush, "send")
 
-    channel.trigger("phx_close")
+      channel.trigger("phx_close")
 
-    clock.tick(1000)
-    assert.equal(spy.callCount, 0)
+      clock.tick(1000)
+      assert.equal(spy.callCount, 0)
 
-    clock.tick(2000)
-    assert.equal(spy.callCount, 0)
-  })
+      clock.tick(2000)
+      assert.equal(spy.callCount, 0)
+    })
 
-  it("triggers additional callbacks", () => {
-    const spy = sinon.spy()
-    channel.onClose(spy)
+    it("triggers additional callbacks", () => {
+      const spy = sinon.spy()
+      channel.onClose(spy)
 
-    assert.equal(spy.callCount, 0)
+      assert.equal(spy.callCount, 0)
 
-    channel.trigger("phx_close")
+      channel.trigger("phx_close")
 
-    assert.equal(spy.callCount, 1)
-  })
+      assert.equal(spy.callCount, 1)
+    })
 
-  it("removes channel from socket", () => {
-    assert.equal(socket.channels.length, 1)
-    assert.deepEqual(socket.channels[0], channel)
+    it("removes channel from socket", () => {
+      assert.equal(socket.channels.length, 1)
+      assert.deepEqual(socket.channels[0], channel)
 
-    channel.trigger("phx_close")
+      channel.trigger("phx_close")
 
-    assert.equal(socket.channels.length, 0)
+      assert.equal(socket.channels.length, 0)
+    })
   })
-})
 
-describe("onMessage", () => {
-  beforeEach(() => {
-    socket = new Socket("/socket")
+  describe("onMessage", () => {
+    beforeEach(() => {
+      socket = new Socket("/socket")
 
-    channel = socket.channel("topic", { one: "two" })
-  })
+      channel = socket.channel("topic", { one: "two" })
+    })
 
-  it("returns payload by default", () => {
-    sinon.stub(socket, "makeRef", () => defaultRef)
-    const payload = channel.onMessage("event", { one: "two" }, defaultRef)
+    it("returns payload by default", () => {
+      sinon.stub(socket, "makeRef", () => defaultRef)
+      const payload = channel.onMessage("event", { one: "two" }, defaultRef)
 
-    assert.deepEqual(payload, { one: "two" })
+      assert.deepEqual(payload, { one: "two" })
+    })
   })
-})
 
-describe("canPush", () => {
-  beforeEach(() => {
-    socket = new Socket("/socket")
+  describe("canPush", () => {
+    beforeEach(() => {
+      socket = new Socket("/socket")
 
-    channel = socket.channel("topic", { one: "two" })
-  })
+      channel = socket.channel("topic", { one: "two" })
+    })
 
-  it("returns true when socket connected and channel joined", () => {
-    sinon.stub(socket, "isConnected").returns(true)
-    channel.state = "joined"
+    it("returns true when socket connected and channel joined", () => {
+      sinon.stub(socket, "isConnected").returns(true)
+      channel.state = "joined"
 
-    assert.ok(channel.canPush())
-  })
+      assert.ok(channel.canPush())
+    })
 
-  it("otherwise returns false", () => {
-    const isConnectedStub = sinon.stub(socket, "isConnected")
+    it("otherwise returns false", () => {
+      const isConnectedStub = sinon.stub(socket, "isConnected")
 
-    isConnectedStub.returns(false)
-    channel.state = "joined"
+      isConnectedStub.returns(false)
+      channel.state = "joined"
 
-    assert.ok(!channel.canPush())
+      assert.ok(!channel.canPush())
 
-    isConnectedStub.returns(true)
-    channel.state = "joining"
+      isConnectedStub.returns(true)
+      channel.state = "joining"
 
-    assert.ok(!channel.canPush())
+      assert.ok(!channel.canPush())
 
-    isConnectedStub.returns(false)
-    channel.state = "joining"
+      isConnectedStub.returns(false)
+      channel.state = "joining"
 
-    assert.ok(!channel.canPush())
+      assert.ok(!channel.canPush())
+    })
   })
-})
 
-describe("on", () => {
-  beforeEach(() => {
-    socket = new Socket("/socket")
-    sinon.stub(socket, "makeRef", () => defaultRef)
+  describe("on", () => {
+    beforeEach(() => {
+      socket = new Socket("/socket")
+      sinon.stub(socket, "makeRef", () => defaultRef)
 
-    channel = socket.channel("topic", { one: "two" })
-  })
+      channel = socket.channel("topic", { one: "two" })
+    })
 
-  it("sets up callback for event", () => {
-    const spy = sinon.spy()
+    it("sets up callback for event", () => {
+      const spy = sinon.spy()
 
-    channel.trigger("event", {}, defaultRef)
-    assert.ok(!spy.called)
+      channel.trigger("event", {}, defaultRef)
+      assert.ok(!spy.called)
 
-    channel.on("event", spy)
+      channel.on("event", spy)
 
-    channel.trigger("event", {}, defaultRef)
+      channel.trigger("event", {}, defaultRef)
 
-    assert.ok(spy.called)
-  })
+      assert.ok(spy.called)
+    })
 
-  it("other event callbacks are ignored", () => {
-    const spy = sinon.spy()
-    const ignoredSpy = sinon.spy()
+    it("other event callbacks are ignored", () => {
+      const spy = sinon.spy()
+      const ignoredSpy = sinon.spy()
 
-    channel.trigger("event", {}, defaultRef)
+      channel.trigger("event", {}, defaultRef)
 
-    assert.ok(!ignoredSpy.called)
+      assert.ok(!ignoredSpy.called)
 
-    channel.on("event", spy)
+      channel.on("event", spy)
 
-    channel.trigger("event", {}, defaultRef)
+      channel.trigger("event", {}, defaultRef)
 
-    assert.ok(!ignoredSpy.called)
-  })
+      assert.ok(!ignoredSpy.called)
+    })
 
-  it("generates unique refs for callbacks", () => {
-    const ref1 = channel.on("event1", () => 0)
-    const ref2 = channel.on("event2", () => 0)
-    assert.equal(ref1 + 1, ref2)
+    it("generates unique refs for callbacks", () => {
+      const ref1 = channel.on("event1", () => 0)
+      const ref2 = channel.on("event2", () => 0)
+      assert.equal(ref1 + 1, ref2)
+    })
   })
-})
 
-describe("off", () => {
-  beforeEach(() => {
-    socket = new Socket("/socket")
-    sinon.stub(socket, "makeRef", () => defaultRef)
+  describe("off", () => {
+    beforeEach(() => {
+      socket = new Socket("/socket")
+      sinon.stub(socket, "makeRef", () => defaultRef)
 
-    channel = socket.channel("topic", { one: "two" })
-  })
+      channel = socket.channel("topic", { one: "two" })
+    })
 
-  it("removes all callbacks for event", () => {
-    const spy1 = sinon.spy()
-    const spy2 = sinon.spy()
-    const spy3 = sinon.spy()
+    it("removes all callbacks for event", () => {
+      const spy1 = sinon.spy()
+      const spy2 = sinon.spy()
+      const spy3 = sinon.spy()
 
-    channel.on("event", spy1)
-    channel.on("event", spy2)
-    channel.on("other", spy3)
+      channel.on("event", spy1)
+      channel.on("event", spy2)
+      channel.on("other", spy3)
 
-    channel.off("event")
+      channel.off("event")
 
-    channel.trigger("event", {}, defaultRef)
-    channel.trigger("other", {}, defaultRef)
+      channel.trigger("event", {}, defaultRef)
+      channel.trigger("other", {}, defaultRef)
 
-    assert.ok(!spy1.called)
-    assert.ok(!spy2.called)
-    assert.ok(spy3.called)
-  })
+      assert.ok(!spy1.called)
+      assert.ok(!spy2.called)
+      assert.ok(spy3.called)
+    })
 
-  it("removes callback by its ref", () => {
-    const spy1 = sinon.spy()
-    const spy2 = sinon.spy()
+    it("removes callback by its ref", () => {
+      const spy1 = sinon.spy()
+      const spy2 = sinon.spy()
 
-    const ref1 = channel.on("event", spy1)
-    const ref2 = channel.on("event", spy2)
+      const ref1 = channel.on("event", spy1)
+      const ref2 = channel.on("event", spy2)
 
-    channel.off("event", ref1)
-    channel.trigger("event", {}, defaultRef)
+      channel.off("event", ref1)
+      channel.trigger("event", {}, defaultRef)
 
-    assert.ok(!spy1.called)
-    assert.ok(spy2.called)
+      assert.ok(!spy1.called)
+      assert.ok(spy2.called)
+    })
   })
-})
-
-describe("push", () => {
-  let clock, joinPush
-  let socketSpy
 
-  let pushParams = (channel) => {
-    return({
-      topic: "topic",
-      event: "event",
-      payload: { foo: "bar" },
-      join_ref: channel.joinRef(),
-      ref: defaultRef
-    })
-  }
+  describe("push", () => {
+    let clock, joinPush
+    let socketSpy
+
+    let pushParams = (channel) => {
+      return({
+        topic: "topic",
+        event: "event",
+        payload: { foo: "bar" },
+        join_ref: channel.joinRef(),
+        ref: defaultRef
+      })
+    }
 
-  beforeEach(() => {
-    clock = sinon.useFakeTimers()
+    beforeEach(() => {
+      clock = sinon.useFakeTimers()
 
-    socket = new Socket("/socket", { timeout: defaultTimeout })
-    sinon.stub(socket, "makeRef", () => defaultRef)
-    sinon.stub(socket, "isConnected", () => true)
-    socketSpy = sinon.stub(socket, "push")
+      socket = new Socket("/socket", { timeout: defaultTimeout })
+      sinon.stub(socket, "makeRef", () => defaultRef)
+      sinon.stub(socket, "isConnected", () => true)
+      socketSpy = sinon.stub(socket, "push")
 
-    channel = socket.channel("topic", { one: "two" })
-  })
+      channel = socket.channel("topic", { one: "two" })
+    })
 
-  afterEach(() => {
-    clock.restore()
-  })
+    afterEach(() => {
+      clock.restore()
+    })
 
-  it("sends push event when successfully joined", () => {
-    channel.join().trigger("ok", {})
-    channel.push("event", { foo: "bar" })
+    it("sends push event when successfully joined", () => {
+      channel.join().trigger("ok", {})
+      channel.push("event", { foo: "bar" })
 
-    assert.ok(socketSpy.calledWith(pushParams(channel)))
-  })
+      assert.ok(socketSpy.calledWith(pushParams(channel)))
+    })
 
-  it("enqueues push event to be sent once join has succeeded", () => {
-    joinPush = channel.join()
-    channel.push("event", { foo: "bar" })
+    it("enqueues push event to be sent once join has succeeded", () => {
+      joinPush = channel.join()
+      channel.push("event", { foo: "bar" })
 
-    assert.ok(!socketSpy.calledWith(pushParams(channel)))
+      assert.ok(!socketSpy.calledWith(pushParams(channel)))
 
-    clock.tick(channel.timeout / 2)
-    joinPush.trigger("ok", {})
+      clock.tick(channel.timeout / 2)
+      joinPush.trigger("ok", {})
 
-    assert.ok(socketSpy.calledWith(pushParams(channel)))
-  })
+      assert.ok(socketSpy.calledWith(pushParams(channel)))
+    })
 
-  it("does not push if channel join times out", () => {
-    joinPush = channel.join()
-    channel.push("event", { foo: "bar" })
+    it("does not push if channel join times out", () => {
+      joinPush = channel.join()
+      channel.push("event", { foo: "bar" })
 
-    assert.ok(!socketSpy.calledWith(pushParams(channel)))
+      assert.ok(!socketSpy.calledWith(pushParams(channel)))
 
-    clock.tick(channel.timeout * 2)
-    joinPush.trigger("ok", {})
+      clock.tick(channel.timeout * 2)
+      joinPush.trigger("ok", {})
 
-    assert.ok(!socketSpy.calledWith(pushParams(channel)))
-  })
+      assert.ok(!socketSpy.calledWith(pushParams(channel)))
+    })
 
-  it("uses channel timeout by default", () => {
-    const timeoutSpy = sinon.spy()
-    channel.join().trigger("ok", {})
+    it("uses channel timeout by default", () => {
+      const timeoutSpy = sinon.spy()
+      channel.join().trigger("ok", {})
 
-    channel.push("event", { foo: "bar" })
-      .receive("timeout", timeoutSpy)
+      channel.push("event", { foo: "bar" })
+        .receive("timeout", timeoutSpy)
 
-    clock.tick(channel.timeout / 2)
-    assert.ok(!timeoutSpy.called)
+      clock.tick(channel.timeout / 2)
+      assert.ok(!timeoutSpy.called)
 
-    clock.tick(channel.timeout)
-    assert.ok(timeoutSpy.called)
-  })
+      clock.tick(channel.timeout)
+      assert.ok(timeoutSpy.called)
+    })
 
-  it("accepts timeout arg", () => {
-    const timeoutSpy = sinon.spy()
-    channel.join().trigger("ok", {})
+    it("accepts timeout arg", () => {
+      const timeoutSpy = sinon.spy()
+      channel.join().trigger("ok", {})
 
-    channel.push("event", { foo: "bar" }, channel.timeout * 2)
-      .receive("timeout", timeoutSpy)
+      channel.push("event", { foo: "bar" }, channel.timeout * 2)
+        .receive("timeout", timeoutSpy)
 
-    clock.tick(channel.timeout)
-    assert.ok(!timeoutSpy.called)
+      clock.tick(channel.timeout)
+      assert.ok(!timeoutSpy.called)
 
-    clock.tick(channel.timeout * 2)
-    assert.ok(timeoutSpy.called)
-  })
+      clock.tick(channel.timeout * 2)
+      assert.ok(timeoutSpy.called)
+    })
 
-  it("does not time out after receiving 'ok'", () => {
-    channel.join().trigger("ok", {})
-    const timeoutSpy = sinon.spy()
-    const push = channel.push("event", { foo: "bar" })
-    push.receive("timeout", timeoutSpy)
+    it("does not time out after receiving 'ok'", () => {
+      channel.join().trigger("ok", {})
+      const timeoutSpy = sinon.spy()
+      const push = channel.push("event", { foo: "bar" })
+      push.receive("timeout", timeoutSpy)
 
-    clock.tick(push.timeout / 2)
-    assert.ok(!timeoutSpy.called)
+      clock.tick(push.timeout / 2)
+      assert.ok(!timeoutSpy.called)
 
-    push.trigger("ok", {})
+      push.trigger("ok", {})
 
-    clock.tick(push.timeout)
-    assert.ok(!timeoutSpy.called)
-  })
+      clock.tick(push.timeout)
+      assert.ok(!timeoutSpy.called)
+    })
 
-  it("throws if channel has not been joined", () => {
-    assert.throws(() => channel.push("event", {}), /^Error: tried to push.*before joining/)
+    it("throws if channel has not been joined", () => {
+      assert.throws(() => channel.push("event", {}), /^Error: tried to push.*before joining/)
+    })
   })
-})
 
-describe("leave", () => {
-  let clock, joinPush
-  let socketSpy
+  describe("leave", () => {
+    let clock, joinPush
+    let socketSpy
 
-  beforeEach(() => {
-    clock = sinon.useFakeTimers()
+    beforeEach(() => {
+      clock = sinon.useFakeTimers()
 
-    socket = new Socket("/socket", { timeout: defaultTimeout })
-    sinon.stub(socket, "isConnected", () => true)
-    socketSpy = sinon.stub(socket, "push")
+      socket = new Socket("/socket", { timeout: defaultTimeout })
+      sinon.stub(socket, "isConnected", () => true)
+      socketSpy = sinon.stub(socket, "push")
 
-    channel = socket.channel("topic", { one: "two" })
-    channel.join().trigger("ok", {})
-  })
+      channel = socket.channel("topic", { one: "two" })
+      channel.join().trigger("ok", {})
+    })
 
-  afterEach(() => {
-    clock.restore()
-  })
+    afterEach(() => {
+      clock.restore()
+    })
 
-  it("unsubscribes from server events", () => {
-    sinon.stub(socket, "makeRef", () => defaultRef)
-    const joinRef = channel.joinRef()
+    it("unsubscribes from server events", () => {
+      sinon.stub(socket, "makeRef", () => defaultRef)
+      const joinRef = channel.joinRef()
 
-    channel.leave()
+      channel.leave()
 
-    assert.ok(socketSpy.calledWith({
-      topic: "topic",
-      event: "phx_leave",
-      payload: {},
-      ref: defaultRef,
-      join_ref: joinRef
-    }))
-  })
+      assert.ok(socketSpy.calledWith({
+        topic: "topic",
+        event: "phx_leave",
+        payload: {},
+        ref: defaultRef,
+        join_ref: joinRef
+      }))
+    })
 
-  it("closes channel on 'ok' from server", () => {
-    const anotherChannel = socket.channel("another", { three: "four" })
-    assert.equal(socket.channels.length, 2)
+    it("closes channel on 'ok' from server", () => {
+      const anotherChannel = socket.channel("another", { three: "four" })
+      assert.equal(socket.channels.length, 2)
 
-    channel.leave().trigger("ok", {})
+      channel.leave().trigger("ok", {})
 
-    assert.equal(socket.channels.length, 1)
-    assert.deepEqual(socket.channels[0], anotherChannel)
-  })
+      assert.equal(socket.channels.length, 1)
+      assert.deepEqual(socket.channels[0], anotherChannel)
+    })
 
-  it("sets state to closed on 'ok' event", () => {
-    assert.notEqual(channel.state, "closed")
+    it("sets state to closed on 'ok' event", () => {
+      assert.notEqual(channel.state, "closed")
 
-    channel.leave().trigger("ok", {})
+      channel.leave().trigger("ok", {})
 
-    assert.equal(channel.state, "closed")
-  })
+      assert.equal(channel.state, "closed")
+    })
 
-  // TODO - the following tests are skipped until Channel.leave
-  // behavior can be fixed; currently, 'ok' is triggered immediately
-  // within Channel.leave so timeout callbacks are never reached
-  //
-  it.skip("sets state to leaving initially", () => {
-    assert.notEqual(channel.state, "leaving")
+    // TODO - the following tests are skipped until Channel.leave
+    // behavior can be fixed; currently, 'ok' is triggered immediately
+    // within Channel.leave so timeout callbacks are never reached
+    //
+    it.skip("sets state to leaving initially", () => {
+      assert.notEqual(channel.state, "leaving")
 
-    channel.leave()
+      channel.leave()
 
-    assert.equal(channel.state, "leaving")
-  })
+      assert.equal(channel.state, "leaving")
+    })
 
-  it.skip("closes channel on 'timeout'", () => {
-    channel.leave()
+    it.skip("closes channel on 'timeout'", () => {
+      channel.leave()
 
-    clock.tick(channel.timeout)
+      clock.tick(channel.timeout)
 
-    assert.equal(channel.state, "closed")
-  })
+      assert.equal(channel.state, "closed")
+    })
 
-  it.skip("accepts timeout arg", () => {
-    channel.leave(channel.timeout * 2)
+    it.skip("accepts timeout arg", () => {
+      channel.leave(channel.timeout * 2)
 
-    clock.tick(channel.timeout)
+      clock.tick(channel.timeout)
 
-    assert.equal(channel.state, "leaving")
+      assert.equal(channel.state, "leaving")
 
-    clock.tick(channel.timeout * 2)
+      clock.tick(channel.timeout * 2)
 
-    assert.equal(channel.state, "closed")
+      assert.equal(channel.state, "closed")
+    })
   })
+
 })
diff --git a/assets/test/socket_test.js b/assets/test/socket_test.js
index 4790db775..a036990d3 100644
--- a/assets/test/socket_test.js
+++ b/assets/test/socket_test.js
@@ -8,825 +8,864 @@ import {Socket, LongPoll} from "../js/phoenix"
 
 let socket
 
-describe("constructor", () => {
+describe("with transports", done =>{
   before(() => {
-    window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
+    window.WebSocket = WebSocket
   })
 
-  after(() => {
-    window.XMLHttpRequest = null
+  after((done) => {
+    window.WebSocket = null
+    done()
   })
 
-  it("sets defaults", () => {
-    socket = new Socket("/socket")
-
-    assert.equal(socket.channels.length, 0)
-    assert.equal(socket.sendBuffer.length, 0)
-    assert.equal(socket.ref, 0)
-    assert.equal(socket.endPoint, "/socket/websocket")
-    assert.deepEqual(socket.stateChangeCallbacks, {open: [], close: [], error: [], message: []})
-    assert.equal(socket.transport, LongPoll)
-    assert.equal(socket.timeout, 10000)
-    assert.equal(socket.longpollerTimeout, 20000)
-    assert.equal(socket.heartbeatIntervalMs, 30000)
-    assert.equal(socket.logger, null)
-    assert.equal(typeof socket.reconnectAfterMs, "function")
-  })
+  describe("constructor", () => {
+    it("sets defaults", () => {
+      socket = new Socket("/socket")
 
-  it("supports closure or literal params", () => {
-    socket = new Socket("/socket", {params: {one: "two"}})
-    assert.deepEqual(socket.params(), {one: "two"})
+      assert.equal(socket.channels.length, 0)
+      assert.equal(socket.sendBuffer.length, 0)
+      assert.equal(socket.ref, 0)
+      assert.equal(socket.endPoint, "/socket/websocket")
+      assert.deepEqual(socket.stateChangeCallbacks, {open: [], close: [], error: [], message: []})
+      assert.equal(socket.transport, WebSocket)
+      assert.equal(socket.timeout, 10000)
+      assert.equal(socket.longpollerTimeout, 20000)
+      assert.equal(socket.heartbeatIntervalMs, 30000)
+      assert.equal(socket.logger, null)
+      assert.equal(socket.binaryType, "arraybuffer")
+      assert.equal(typeof socket.reconnectAfterMs, "function")
+    })
 
-    socket = new Socket("/socket", {params: function(){ return({three: "four"}) }})
-    assert.deepEqual(socket.params(), {three: "four"})
-  })
+    it("supports closure or literal params", () => {
+      socket = new Socket("/socket", {params: {one: "two"}})
+      assert.deepEqual(socket.params(), {one: "two"})
 
-  it("overrides some defaults with options", () => {
-    const customTransport = function transport() {}
-    const customLogger = function logger() {}
-    const customReconnect = function reconnect() {}
-
-    socket = new Socket("/socket", {
-      timeout: 40000,
-      longpollerTimeout: 50000,
-      heartbeatIntervalMs: 60000,
-      transport: customTransport,
-      logger: customLogger,
-      reconnectAfterMs: customReconnect,
-      params: {one: "two"},
-    })
-
-    assert.equal(socket.timeout, 40000)
-    assert.equal(socket.longpollerTimeout, 50000)
-    assert.equal(socket.heartbeatIntervalMs, 60000)
-    assert.equal(socket.transport, customTransport)
-    assert.equal(socket.logger, customLogger)
-    assert.equal(socket.reconnectAfterMs, customReconnect)
-    assert.deepEqual(socket.params(), {one: "two"})
-  })
+      socket = new Socket("/socket", {params: function(){ return({three: "four"}) }})
+      assert.deepEqual(socket.params(), {three: "four"})
+    })
 
-  describe("with Websocket", () => {
-    let mockServer
+    it("overrides some defaults with options", () => {
+      const customTransport = function transport() {}
+      const customLogger = function logger() {}
+      const customReconnect = function reconnect() {}
+
+      socket = new Socket("/socket", {
+        timeout: 40000,
+        longpollerTimeout: 50000,
+        heartbeatIntervalMs: 60000,
+        transport: customTransport,
+        logger: customLogger,
+        reconnectAfterMs: customReconnect,
+        params: {one: "two"},
+      })
 
-    before(() => {
-      mockServer = new WebSocketServer("wss://example.com/")
+      assert.equal(socket.timeout, 40000)
+      assert.equal(socket.longpollerTimeout, 50000)
+      assert.equal(socket.heartbeatIntervalMs, 60000)
+      assert.equal(socket.transport, customTransport)
+      assert.equal(socket.logger, customLogger)
+      assert.deepEqual(socket.params(), {one: "two"})
     })
 
-    after((done) => {
-      mockServer.stop(() => {
-        window.WebSocket = null
-        done()
+    describe("with Websocket", () => {
+      let mockServer
+
+      before(() => {
+        mockServer = new WebSocketServer("wss://example.com/")
       })
-    })
 
-    it("defaults to Websocket transport if available", () => {
-      socket = new Socket("/socket")
-      assert.equal(socket.transport, WebSocket)
+      after((done) => {
+        mockServer.stop(() => done())
+      })
+
+      it("defaults to Websocket transport if available", () => {
+        socket = new Socket("/socket")
+        assert.equal(socket.transport, WebSocket)
+      })
     })
   })
-})
 
-describe("protocol", () => {
-  beforeEach(() => {
-    socket = new Socket("/socket")
-  })
+  describe("protocol", () => {
+    beforeEach(() => {
+      socket = new Socket("/socket")
+    })
 
-  it("returns wss when location.protocol is https", () => {
-    jsdom.changeURL(window, "https://example.com/");
+    it("returns wss when location.protocol is https", () => {
+      jsdom.changeURL(window, "https://example.com/");
 
-    assert.equal(socket.protocol(), "wss")
-  })
+      assert.equal(socket.protocol(), "wss")
+    })
 
-  it("returns ws when location.protocol is http", () => {
-    jsdom.changeURL(window, "http://example.com/");
+    it("returns ws when location.protocol is http", () => {
+      jsdom.changeURL(window, "http://example.com/");
 
-    assert.equal(socket.protocol(), "ws")
+      assert.equal(socket.protocol(), "ws")
+    })
   })
-})
 
-describe("endpointURL", () => {
-  it("returns endpoint for given full url", () => {
-    jsdom.changeURL(window, "https://example.com/");
-    socket = new Socket("wss://example.org/chat")
+  describe("endpointURL", () => {
+    it("returns endpoint for given full url", () => {
+      jsdom.changeURL(window, "https://example.com/");
+      socket = new Socket("wss://example.org/chat")
 
-    assert.equal(socket.endPointURL(), "wss://example.org/chat/websocket?vsn=2.0.0")
-  })
+      assert.equal(socket.endPointURL(), "wss://example.org/chat/websocket?vsn=2.0.0")
+    })
 
-  it("returns endpoint for given protocol-relative url", () => {
-    jsdom.changeURL(window, "https://example.com/");
-    socket = new Socket("//example.org/chat")
+    it("returns endpoint for given protocol-relative url", () => {
+      jsdom.changeURL(window, "https://example.com/");
+      socket = new Socket("//example.org/chat")
 
-    assert.equal(socket.endPointURL(), "wss://example.org/chat/websocket?vsn=2.0.0")
-  })
+      assert.equal(socket.endPointURL(), "wss://example.org/chat/websocket?vsn=2.0.0")
+    })
 
-  it("returns endpoint for given path on https host", () => {
-    jsdom.changeURL(window, "https://example.com/");
-    socket = new Socket("/socket")
+    it("returns endpoint for given path on https host", () => {
+      jsdom.changeURL(window, "https://example.com/");
+      socket = new Socket("/socket")
 
-    assert.equal(socket.endPointURL(), "wss://example.com/socket/websocket?vsn=2.0.0")
-  })
+      assert.equal(socket.endPointURL(), "wss://example.com/socket/websocket?vsn=2.0.0")
+    })
 
-  it("returns endpoint for given path on http host", () => {
-    jsdom.changeURL(window, "http://example.com/");
-    socket = new Socket("/socket")
+    it("returns endpoint for given path on http host", () => {
+      jsdom.changeURL(window, "http://example.com/");
+      socket = new Socket("/socket")
 
-    assert.equal(socket.endPointURL(), "ws://example.com/socket/websocket?vsn=2.0.0")
+      assert.equal(socket.endPointURL(), "ws://example.com/socket/websocket?vsn=2.0.0")
+    })
   })
-})
 
-describe("connect with WebSocket", () => {
-  let mockServer
+  describe("connect with WebSocket", () => {
+    let mockServer
 
-  before(() => {
-    mockServer = new WebSocketServer("wss://example.com/")
-  })
+    before(() => {
+      mockServer = new WebSocketServer("wss://example.com/")
+    })
 
-  after((done) => {
-    mockServer.stop(() => {
-      window.WebSocket = null
-      done()
+    after((done) => {
+      mockServer.stop(() => done())
     })
-  })
 
-  beforeEach(() => {
-    socket = new Socket("/socket")
-  })
+    beforeEach(() => {
+      socket = new Socket("/socket")
+    })
 
-  it("establishes websocket connection with endpoint", () => {
-    socket.connect()
+    it("establishes websocket connection with endpoint", () => {
+      socket.connect()
 
-    let conn = socket.conn
-    assert.ok(conn instanceof WebSocket)
-    assert.equal(conn.url, socket.endPointURL())
-  })
+      let conn = socket.conn
+      assert.ok(conn instanceof WebSocket)
+      assert.equal(conn.url, socket.endPointURL())
+    })
 
-  it("sets callbacks for connection", () => {
-    let opens = 0
-    socket.onOpen(() => ++opens)
-    let closes = 0
-    socket.onClose(() => ++closes)
-    let lastError
-    socket.onError((error) => lastError = error)
-    let lastMessage
-    socket.onMessage((message) => lastMessage = message.payload)
+    it("sets callbacks for connection", () => {
+      let opens = 0
+      socket.onOpen(() => ++opens)
+      let closes = 0
+      socket.onClose(() => ++closes)
+      let lastError
+      socket.onError((error) => lastError = error)
+      let lastMessage
+      socket.onMessage((message) => lastMessage = message.payload)
 
-    socket.connect()
+      socket.connect()
 
-    socket.conn.onopen[0]()
-    assert.equal(opens, 1)
+      socket.conn.onopen[0]()
+      assert.equal(opens, 1)
 
-    socket.conn.onclose[0]()
-    assert.equal(closes, 1)
+      socket.conn.onclose[0]()
+      assert.equal(closes, 1)
 
-    socket.conn.onerror[0]("error")
-    assert.equal(lastError, "error")
+      socket.conn.onerror[0]("error")
+      assert.equal(lastError, "error")
 
-    const data = {"topic":"topic","event":"event","payload":"payload","status":"ok"}
-    socket.conn.onmessage[0]({data: encode(data)})
-    assert.equal(lastMessage, "payload")
-  })
+      const data = {"topic":"topic","event":"event","payload":"payload","status":"ok"}
+      socket.conn.onmessage[0]({data: encode(data)})
+      assert.equal(lastMessage, "payload")
+    })
 
-  it("is idempotent", () => {
-    socket.connect()
+    it("is idempotent", () => {
+      socket.connect()
 
-    let conn = socket.conn
+      let conn = socket.conn
 
-    socket.connect()
+      socket.connect()
 
-    assert.deepStrictEqual(conn, socket.conn)
+      assert.deepStrictEqual(conn, socket.conn)
+    })
   })
-})
 
-describe("connect with long poll", () => {
-  before(() => {
-    window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
-  })
+  describe("connect with long poll", () => {
+    beforeEach(() => {
+      socket = new Socket("/socket", {transport: LongPoll})
+    })
 
-  after(() => {
-    window.XMLHttpRequest = null
-  })
+    it("establishes long poll connection with endpoint", () => {
+      socket.connect()
 
-  beforeEach(() => {
-    socket = new Socket("/socket")
-  })
+      let conn = socket.conn
+      assert.ok(conn instanceof LongPoll)
+      assert.equal(conn.pollEndpoint, "http://example.com/socket/longpoll?vsn=2.0.0")
+      assert.equal(conn.timeout, 20000)
+    })
 
-  it("establishes long poll connection with endpoint", () => {
-    socket.connect()
+    it("sets callbacks for connection", () => {
+      let opens = 0
+      socket.onOpen(() => ++opens)
+      let closes = 0
+      socket.onClose(() => ++closes)
+      let lastError
+      socket.onError((error) => lastError = error)
+      let lastMessage
+      socket.onMessage((message) => lastMessage = message.payload)
 
-    let conn = socket.conn
-    assert.ok(conn instanceof LongPoll)
-    assert.equal(conn.pollEndpoint, "http://example.com/socket/longpoll?vsn=2.0.0")
-    assert.equal(conn.timeout, 20000)
-  })
+      socket.connect()
 
-  it("sets callbacks for connection", () => {
-    let opens = 0
-    socket.onOpen(() => ++opens)
-    let closes = 0
-    socket.onClose(() => ++closes)
-    let lastError
-    socket.onError((error) => lastError = error)
-    let lastMessage
-    socket.onMessage((message) => lastMessage = message.payload)
+      socket.conn.onopen()
+      assert.equal(opens, 1)
 
-    socket.connect()
+      socket.conn.onclose()
+      assert.equal(closes, 1)
 
-    socket.conn.onopen()
-    assert.equal(opens, 1)
+      socket.conn.onerror("error")
 
-    socket.conn.onclose()
-    assert.equal(closes, 1)
+      assert.equal(lastError, "error")
 
-    socket.conn.onerror("error")
+      socket.connect()
 
-    assert.equal(lastError, "error")
+      const data = {"topic":"topic","event":"event","payload":"payload","status":"ok"}
 
-    socket.connect()
+      socket.conn.onmessage({data: encode(data)})
+      assert.equal(lastMessage, "payload")
+    })
+
+    it("is idempotent", () => {
+      socket.connect()
 
-    const data = {"topic":"topic","event":"event","payload":"payload","status":"ok"}
+      let conn = socket.conn
 
-    socket.conn.onmessage({data: encode(data)})
-    assert.equal(lastMessage, "payload")
+      socket.connect()
+
+      assert.deepStrictEqual(conn, socket.conn)
+    })
   })
 
-  it("is idempotent", () => {
-    socket.connect()
+  describe("disconnect", () => {
+    let mockServer
 
-    let conn = socket.conn
+    before(() => {
+      mockServer = new WebSocketServer('wss://example.com/')
+    })
 
-    socket.connect()
+    after((done) => {
+      mockServer.stop(() => done())
+    })
 
-    assert.deepStrictEqual(conn, socket.conn)
-  })
-})
+    beforeEach(() => {
+      socket = new Socket("/socket")
+    })
 
-describe("disconnect", () => {
-  let mockServer
+    it("removes existing connection", () => {
+      socket.connect()
+      socket.disconnect()
 
-  before(() => {
-    mockServer = new WebSocketServer('wss://example.com/')
-  })
+      assert.equal(socket.conn, null)
+    })
 
-  after((done) => {
-    mockServer.stop(() => {
-      window.WebSocket = null
-      done()
+    it("calls callback", () => {
+      let count = 0
+      socket.connect()
+      socket.disconnect(() => count++)
+
+      assert.equal(count, 1)
     })
-  })
 
-  beforeEach(() => {
-    socket = new Socket("/socket")
-  })
+    it("calls connection close callback", () => {
+      socket.connect()
+      const spy = sinon.spy(socket.conn, "close")
 
-  it("removes existing connection", () => {
-    socket.connect()
-    socket.disconnect()
+      socket.disconnect(null, "code", "reason")
 
-    assert.equal(socket.conn, null)
+      assert(spy.calledWith("code", "reason"))
+    })
+
+    it("does not throw when no connection", () => {
+      assert.doesNotThrow(() => {
+        socket.disconnect()
+      })
+    })
   })
 
-  it("calls callback", () => {
-    let count = 0
-    socket.connect()
-    socket.disconnect(() => count++)
+  describe("connectionState", () => {
+    before(() => {
+      window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
+    })
 
-    assert.equal(count, 1)
-  })
+    after(() => {
+      window.XMLHttpRequest = null
+    })
 
-  it("calls connection close callback", () => {
-    socket.connect()
-    const spy = sinon.spy(socket.conn, "close")
+    beforeEach(() => {
+      socket = new Socket("/socket")
+    })
 
-    socket.disconnect(null, "code", "reason")
+    it("defaults to closed", () => {
+      assert.equal(socket.connectionState(), "closed")
+    })
 
-    assert(spy.calledWith("code", "reason"))
-  })
+    it("returns closed if readyState unrecognized", () => {
+      socket.connect()
 
-  it("does not throw when no connection", () => {
-    assert.doesNotThrow(() => {
-      socket.disconnect()
+      socket.conn.readyState = 5678
+      assert.equal(socket.connectionState(), "closed")
     })
-  })
-})
 
-describe("connectionState", () => {
-  before(() => {
-    window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
-  })
+    it("returns connecting", () => {
+      socket.connect()
 
-  after(() => {
-    window.XMLHttpRequest = null
-  })
+      socket.conn.readyState = 0
+      assert.equal(socket.connectionState(), "connecting")
+      assert.ok(!socket.isConnected(), "is not connected")
+    })
 
-  beforeEach(() => {
-    socket = new Socket("/socket")
-  })
+    it("returns open", () => {
+      socket.connect()
 
-  it("defaults to closed", () => {
-    assert.equal(socket.connectionState(), "closed")
-  })
+      socket.conn.readyState = 1
+      assert.equal(socket.connectionState(), "open")
+      assert.ok(socket.isConnected(), "is connected")
+    })
 
-  it("returns closed if readyState unrecognized", () => {
-    socket.connect()
+    it("returns closing", () => {
+      socket.connect()
 
-    socket.conn.readyState = 5678
-    assert.equal(socket.connectionState(), "closed")
-  })
+      socket.conn.readyState = 2
+      assert.equal(socket.connectionState(), "closing")
+      assert.ok(!socket.isConnected(), "is not connected")
+    })
 
-  it("returns connecting", () => {
-    socket.connect()
+    it("returns closed", () => {
+      socket.connect()
 
-    socket.conn.readyState = 0
-    assert.equal(socket.connectionState(), "connecting")
-    assert.ok(!socket.isConnected(), "is not connected")
+      socket.conn.readyState = 3
+      assert.equal(socket.connectionState(), "closed")
+      assert.ok(!socket.isConnected(), "is not connected")
+    })
   })
 
-  it("returns open", () => {
-    socket.connect()
+  describe("channel", () => {
+    let channel
 
-    socket.conn.readyState = 1
-    assert.equal(socket.connectionState(), "open")
-    assert.ok(socket.isConnected(), "is connected")
- })
+    beforeEach(() => {
+      socket = new Socket("/socket")
+    })
 
-  it("returns closing", () => {
-    socket.connect()
+    it("returns channel with given topic and params", () => {
+      channel = socket.channel("topic", {one: "two"})
 
-    socket.conn.readyState = 2
-    assert.equal(socket.connectionState(), "closing")
-    assert.ok(!socket.isConnected(), "is not connected")
-  })
+      assert.deepStrictEqual(channel.socket, socket)
+      assert.equal(channel.topic, "topic")
+      assert.deepEqual(channel.params(), {one: "two"})
+    })
 
-  it("returns closed", () => {
-    socket.connect()
+    it("adds channel to sockets channels list", () => {
+      assert.equal(socket.channels.length, 0)
 
-    socket.conn.readyState = 3
-    assert.equal(socket.connectionState(), "closed")
-    assert.ok(!socket.isConnected(), "is not connected")
-  })
-})
+      channel = socket.channel("topic", {one: "two"})
 
-describe("channel", () => {
-  let channel
+      assert.equal(socket.channels.length, 1)
 
-  beforeEach(() => {
-    socket = new Socket("/socket")
+      const [foundChannel] = socket.channels
+      assert.deepStrictEqual(foundChannel, channel)
+    })
   })
 
-  it("returns channel with given topic and params", () => {
-    channel = socket.channel("topic", {one: "two"})
+  describe("remove", () => {
+    beforeEach(() => {
+      socket = new Socket("/socket")
+    })
 
-    assert.deepStrictEqual(channel.socket, socket)
-    assert.equal(channel.topic, "topic")
-    assert.deepEqual(channel.params(), {one: "two"})
-  })
+    it("removes given channel from channels", () => {
+      const channel1 = socket.channel("topic-1")
+      const channel2 = socket.channel("topic-2")
 
-  it("adds channel to sockets channels list", () => {
-    assert.equal(socket.channels.length, 0)
+      sinon.stub(channel1, "joinRef").returns(1)
+      sinon.stub(channel2, "joinRef").returns(2)
 
-    channel = socket.channel("topic", {one: "two"})
+      socket.remove(channel1)
 
-    assert.equal(socket.channels.length, 1)
+      assert.equal(socket.channels.length, 1)
 
-    const [foundChannel] = socket.channels
-    assert.deepStrictEqual(foundChannel, channel)
+      const [foundChannel] = socket.channels
+      assert.deepStrictEqual(foundChannel, channel2)
+    })
   })
-})
 
-describe("remove", () => {
-  beforeEach(() => {
-    socket = new Socket("/socket")
-  })
+  describe("push", () => {
+    const data = {topic: "topic", event: "event", payload: "payload", ref: "ref"}
+    const json = encode(data)
 
-  it("removes given channel from channels", () => {
-    const channel1 = socket.channel("topic-1")
-    const channel2 = socket.channel("topic-2")
+    before(() => {
+      window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
+    })
+
+    after(() => {
+      window.XMLHttpRequest = null
+    })
 
-    sinon.stub(channel1, "joinRef").returns(1)
-    sinon.stub(channel2, "joinRef").returns(2)
+    beforeEach(() => {
+      socket = new Socket("/socket")
+    })
 
-    socket.remove(channel1)
+    it("sends data to connection when connected", () => {
+      socket.connect()
+      socket.conn.readyState = 1 // open
 
-    assert.equal(socket.channels.length, 1)
+      const spy = sinon.spy(socket.conn, "send")
 
-    const [foundChannel] = socket.channels
-    assert.deepStrictEqual(foundChannel, channel2)
-  })
-})
+      socket.push(data)
 
-describe("push", () => {
-  const data = {topic: "topic", event: "event", payload: "payload", ref: "ref"}
-  const json = encode(data)
+      assert.ok(spy.calledWith(json))
+    })
 
-  before(() => {
-    window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
-  })
+    it("buffers data when not connected", () => {
+      socket.connect()
+      socket.conn.readyState = 0 // connecting
 
-  after(() => {
-    window.XMLHttpRequest = null
-  })
+      const spy = sinon.spy(socket.conn, "send")
 
-  beforeEach(() => {
-    socket = new Socket("/socket")
+      assert.equal(socket.sendBuffer.length, 0)
+
+      socket.push(data)
+
+      assert.ok(spy.neverCalledWith(json))
+      assert.equal(socket.sendBuffer.length, 1)
+
+      const [callback] = socket.sendBuffer
+      callback()
+      assert.ok(spy.calledWith(json))
+    })
   })
 
-  it("sends data to connection when connected", () => {
-    socket.connect()
-    socket.conn.readyState = 1 // open
+  describe("makeRef", () => {
+    beforeEach(() => {
+      socket = new Socket("/socket")
+    })
 
-    const spy = sinon.spy(socket.conn, "send")
+    it("returns next message ref", () => {
+      assert.strictEqual(socket.ref, 0)
+      assert.strictEqual(socket.makeRef(), "1")
+      assert.strictEqual(socket.ref, 1)
+      assert.strictEqual(socket.makeRef(), "2")
+      assert.strictEqual(socket.ref, 2)
+    })
 
-    socket.push(data)
+    it("restarts for overflow", () => {
+      socket.ref = Number.MAX_SAFE_INTEGER + 1
 
-    assert.ok(spy.calledWith(json))
+      assert.strictEqual(socket.makeRef(), "0")
+      assert.strictEqual(socket.ref, 0)
+    })
   })
 
-  it("buffers data when not connected", () => {
-    socket.connect()
-    socket.conn.readyState = 0 // connecting
+  describe("sendHeartbeat", () => {
+    before(() => {
+      window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
+    })
 
-    const spy = sinon.spy(socket.conn, "send")
+    after(() => {
+      window.XMLHttpRequest = null
+    })
 
-    assert.equal(socket.sendBuffer.length, 0)
+    beforeEach(() => {
+      socket = new Socket("/socket")
+      socket.connect()
+    })
 
-    socket.push(data)
+    it("closes socket when heartbeat is not ack'd within heartbeat window", () => {
+      let closed = false
+      socket.conn.readyState = 1 // open
+      socket.conn.onclose = () => closed = true
+      socket.sendHeartbeat()
+      assert.equal(closed, false)
 
-    assert.ok(spy.neverCalledWith(json))
-    assert.equal(socket.sendBuffer.length, 1)
+      socket.sendHeartbeat()
+      assert.equal(closed, true)
+    })
 
-    const [callback] = socket.sendBuffer
-    callback()
-    assert.ok(spy.calledWith(json))
-  })
-})
+    it("pushes heartbeat data when connected", () => {
+      socket.conn.readyState = 1 // open
 
-describe("makeRef", () => {
-  beforeEach(() => {
-    socket = new Socket("/socket")
-  })
+      const spy = sinon.spy(socket.conn, "send")
+      const data = "[null,\"1\",\"phoenix\",\"heartbeat\",{}]"
 
-  it("returns next message ref", () => {
-    assert.strictEqual(socket.ref, 0)
-    assert.strictEqual(socket.makeRef(), "1")
-    assert.strictEqual(socket.ref, 1)
-    assert.strictEqual(socket.makeRef(), "2")
-    assert.strictEqual(socket.ref, 2)
-  })
+      socket.sendHeartbeat()
+      assert.ok(spy.calledWith(data))
+    })
 
-  it("restarts for overflow", () => {
-    socket.ref = Number.MAX_SAFE_INTEGER + 1
+    it("no ops when not connected", () => {
+      socket.conn.readyState = 0 // connecting
 
-    assert.strictEqual(socket.makeRef(), "0")
-    assert.strictEqual(socket.ref, 0)
-  })
-})
+      const spy = sinon.spy(socket.conn, "send")
+      const data = encode({topic: "phoenix", event: "heartbeat", payload: {},ref: "1"})
 
-describe("sendHeartbeat", () => {
-  before(() => {
-    window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
+      socket.sendHeartbeat()
+      assert.ok(spy.neverCalledWith(data))
+    })
   })
 
-  after(() => {
-    window.XMLHttpRequest = null
-  })
+  describe("flushSendBuffer", () => {
+    before(() => {
+      window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
+    })
 
-  beforeEach(() => {
-    socket = new Socket("/socket")
-    socket.connect()
-  })
+    after(() => {
+      window.XMLHttpRequest = null
+    })
+
+    beforeEach(() => {
+      socket = new Socket("/socket")
+      socket.connect()
+    })
 
-  it("closes socket when heartbeat is not ack'd within heartbeat window", () => {
-    let closed = false
-    socket.conn.readyState = 1 // open
-    socket.conn.onclose = () => closed = true
-    socket.sendHeartbeat()
-    assert.equal(closed, false)
+    it("calls callbacks in buffer when connected", () => {
+      socket.conn.readyState = 1 // open
+      const spy1 = sinon.spy()
+      const spy2 = sinon.spy()
+      const spy3 = sinon.spy()
+      socket.sendBuffer.push(spy1)
+      socket.sendBuffer.push(spy2)
 
-    socket.sendHeartbeat()
-    assert.equal(closed, true)
-  })
+      socket.flushSendBuffer()
+
+      assert.ok(spy1.calledOnce)
+      assert.ok(spy2.calledOnce)
+      assert.equal(spy3.callCount, 0)
+    })
 
-  it("pushes heartbeat data when connected", () => {
-    socket.conn.readyState = 1 // open
+    it("empties sendBuffer", () => {
+      socket.conn.readyState = 1 // open
+      socket.sendBuffer.push(() => {})
 
-    const spy = sinon.spy(socket.conn, "send")
-    const data = "[null,\"1\",\"phoenix\",\"heartbeat\",{}]"
+      socket.flushSendBuffer()
 
-    socket.sendHeartbeat()
-    assert.ok(spy.calledWith(data))
+      assert.deepEqual(socket.sendBuffer.length, 0)
+    })
   })
 
-  it("no ops when not connected", () => {
-    socket.conn.readyState = 0 // connecting
+  describe("onConnOpen", () => {
+    let mockServer
 
-    const spy = sinon.spy(socket.conn, "send")
-    const data = encode({topic: "phoenix", event: "heartbeat", payload: {},ref: "1"})
+    before(() => {
+      mockServer = new WebSocketServer('wss://example.com/')
+    })
 
-    socket.sendHeartbeat()
-    assert.ok(spy.neverCalledWith(data))
-  })
-})
+    after((done) => {
+      mockServer.stop(() => done())
+    })
 
-describe("flushSendBuffer", () => {
-  before(() => {
-    window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
-  })
+    beforeEach(() => {
+      socket = new Socket("/socket", {
+        reconnectAfterMs: () => 100000
+      })
+      socket.connect()
+    })
 
-  after(() => {
-    window.XMLHttpRequest = null
-  })
+    it("flushes the send buffer", () => {
+      socket.conn.readyState = 1 // open
+      const spy = sinon.spy()
+      socket.sendBuffer.push(spy)
 
-  beforeEach(() => {
-    socket = new Socket("/socket")
-    socket.connect()
-  })
+      socket.onConnOpen()
 
-  it("calls callbacks in buffer when connected", () => {
-    socket.conn.readyState = 1 // open
-    const spy1 = sinon.spy()
-    const spy2 = sinon.spy()
-    const spy3 = sinon.spy()
-    socket.sendBuffer.push(spy1)
-    socket.sendBuffer.push(spy2)
+      assert.ok(spy.calledOnce)
+    })
 
-    socket.flushSendBuffer()
+    it("resets reconnectTimer", () => {
+      const spy = sinon.spy(socket.reconnectTimer, "reset")
 
-    assert.ok(spy1.calledOnce)
-    assert.ok(spy2.calledOnce)
-    assert.equal(spy3.callCount, 0)
-  })
+      socket.onConnOpen()
 
-  it("empties sendBuffer", () => {
-    socket.conn.readyState = 1 // open
-    socket.sendBuffer.push(() => {})
+      assert.ok(spy.calledOnce)
+    })
 
-    socket.flushSendBuffer()
+    it("triggers onOpen callback", () => {
+      const spy = sinon.spy()
 
-    assert.deepEqual(socket.sendBuffer.length, 0)
-  })
-})
+      socket.onOpen(spy)
 
-describe("onConnOpen", () => {
-  let mockServer
+      socket.onConnOpen()
 
-  before(() => {
-    mockServer = new WebSocketServer('wss://example.com/')
+      assert.ok(spy.calledOnce)
+    })
   })
 
-  after((done) => {
-    mockServer.stop(() => {
-      window.WebSocket = null
-      done()
+  describe("onConnClose", () => {
+    let mockServer
+
+    before(() => {
+      mockServer = new WebSocketServer('wss://example.com/')
     })
-  })
 
-  beforeEach(() => {
-    socket = new Socket("/socket", {
-      reconnectAfterMs: () => 100000
+    after((done) => {
+      mockServer.stop(() => done())
     })
-    socket.connect()
-  })
 
-  it("flushes the send buffer", () => {
-    socket.conn.readyState = 1 // open
-    const spy = sinon.spy()
-    socket.sendBuffer.push(spy)
+    beforeEach(() => {
+      socket = new Socket("/socket", {
+        reconnectAfterMs: () => 100000
+      })
+      socket.connect()
+    })
 
-    socket.onConnOpen()
+    it('schedules reconnectTimer timeout if normal close', () => {
+      const spy = sinon.spy(socket.reconnectTimer, 'scheduleTimeout')
 
-    assert.ok(spy.calledOnce)
-  })
+      const event = { code: 1000 }
 
-  it("resets reconnectTimer", () => {
-    const spy = sinon.spy(socket.reconnectTimer, "reset")
+      socket.onConnClose(event)
 
-    socket.onConnOpen()
+      assert.ok(spy.calledOnce)
+    })
 
-    assert.ok(spy.calledOnce)
-  })
+    it('does not schedule reconnectTimer timeout if normal close after explicit disconnect', () => {
+      const spy = sinon.spy(socket.reconnectTimer, 'scheduleTimeout')
 
-  it("triggers onOpen callback", () => {
-    const spy = sinon.spy()
+      const event = { code: 1000 }
 
-    socket.onOpen(spy)
+      socket.disconnect()
 
-    socket.onConnOpen()
+      assert.ok(spy.notCalled)
+    })
 
-    assert.ok(spy.calledOnce)
-  })
-})
+    it('schedules reconnectTimer timeout if not normal close', () => {
+      const spy = sinon.spy(socket.reconnectTimer, 'scheduleTimeout')
 
-describe("onConnClose", () => {
-  let mockServer
+      const event = { code: 1001 }
 
-  before(() => {
-    mockServer = new WebSocketServer('wss://example.com/')
-  })
+      socket.onConnClose(event)
 
-  after((done) => {
-    mockServer.stop(() => {
-      window.WebSocket = null
-      done()
+      assert.ok(spy.calledOnce)
     })
-  })
 
-  beforeEach(() => {
-    socket = new Socket("/socket", {
-      reconnectAfterMs: () => 100000
-    })
-    socket.connect()
-  })
+    it("triggers onClose callback", () => {
+      const spy = sinon.spy()
+
+      socket.onClose(spy)
 
-  it('does not schedules reconnectTimer timeout if normal close', () => {
-    const spy = sinon.spy(socket.reconnectTimer, 'scheduleTimeout')
+      socket.onConnClose("event")
 
-    const event = { code: 1000 }
+      assert.ok(spy.calledWith("event"))
+    })
 
-    socket.onConnClose(event)
+    it("triggers channel error if joining", () => {
+      const channel = socket.channel("topic")
+      const spy = sinon.spy(channel, "trigger")
+      channel.join()
+      assert.equal(channel.state, "joining")
 
-    assert.ok(spy.notCalled)
-  })
+      socket.onConnClose()
 
-  it('schedules reconnectTimer timeout if not normal close', () => {
-    const spy = sinon.spy(socket.reconnectTimer, 'scheduleTimeout')
+      assert.ok(spy.calledWith("phx_error"))
+    })
 
-    const event = { code: 1001 }
+    it("triggers channel error if joined", () => {
+      const channel = socket.channel("topic")
+      const spy = sinon.spy(channel, "trigger")
+      channel.join().trigger("ok", {})
 
-    socket.onConnClose(event)
+      assert.equal(channel.state, "joined")
 
-    assert.ok(spy.calledOnce)
-  })
+      socket.onConnClose()
 
-  it("triggers onClose callback", () => {
-    const spy = sinon.spy()
+      assert.ok(spy.calledWith("phx_error"))
+    })
 
-    socket.onClose(spy)
+    it("does not trigger channel error after leave", () => {
+      const channel = socket.channel("topic")
+      const spy = sinon.spy(channel, "trigger")
+      channel.join().trigger("ok", {})
+      channel.leave()
+      assert.equal(channel.state, "closed")
 
-    socket.onConnClose("event")
+      socket.onConnClose()
 
-    assert.ok(spy.calledWith("event"))
+      assert.ok(!spy.calledWith("phx_error"))
+    })
   })
 
-  it("triggers channel error", () => {
-    const channel = socket.channel("topic")
-    const spy = sinon.spy(channel, "trigger")
+  describe("onConnError", () => {
+    let mockServer
 
-    socket.onConnClose()
+    before(() => {
+      mockServer = new WebSocketServer('wss://example.com/')
+    })
 
-    assert.ok(spy.calledWith("phx_error"))
-  })
-})
+    after((done) => {
+      mockServer.stop(() => done())
+    })
 
-describe("onConnError", () => {
-  let mockServer
+    beforeEach(() => {
+      socket = new Socket("/socket", {
+        reconnectAfterMs: () => 100000
+      })
+      socket.connect()
+    })
 
-  before(() => {
-    mockServer = new WebSocketServer('wss://example.com/')
-  })
+    it("triggers onClose callback", () => {
+      const spy = sinon.spy()
 
-  after((done) => {
-    mockServer.stop(() => {
-      window.WebSocket = null
-      done()
+      socket.onError(spy)
+
+      socket.onConnError("error")
+
+      assert.ok(spy.calledWith("error"))
     })
-  })
 
-  beforeEach(() => {
-    socket = new Socket("/socket", {
-      reconnectAfterMs: () => 100000
+    it("triggers channel error if joining", () => {
+      const channel = socket.channel("topic")
+      const spy = sinon.spy(channel, "trigger")
+      channel.join()
+      assert.equal(channel.state, "joining")
+
+      socket.onConnError("error")
+
+      assert.ok(spy.calledWith("phx_error"))
     })
-    socket.connect()
-  })
 
-  it("triggers onClose callback", () => {
-    const spy = sinon.spy()
+    it("triggers channel error if joined", () => {
+      const channel = socket.channel("topic")
+      const spy = sinon.spy(channel, "trigger")
+      channel.join().trigger("ok", {})
 
-    socket.onError(spy)
+      assert.equal(channel.state, "joined")
 
-    socket.onConnError("error")
+      socket.onConnError("error")
 
-    assert.ok(spy.calledWith("error"))
-  })
+      assert.ok(spy.calledWith("phx_error"))
+    })
 
-  it("triggers channel error", () => {
-    const channel = socket.channel("topic")
-    const spy = sinon.spy(channel, "trigger")
+    it("does not trigger channel error after leave", () => {
+      const channel = socket.channel("topic")
+      const spy = sinon.spy(channel, "trigger")
+      channel.join().trigger("ok", {})
+      channel.leave()
+      assert.equal(channel.state, "closed")
 
-    socket.onConnError("error")
+      socket.onConnError("error")
 
-    assert.ok(spy.calledWith("phx_error"))
+      assert.ok(!spy.calledWith("phx_error"))
+    })
   })
-})
 
-describe("onConnMessage", () => {
-  let mockServer
+  describe("onConnMessage", () => {
+    let mockServer
 
-  before(() => {
-    mockServer = new WebSocketServer('wss://example.com/')
-  })
+    before(() => {
+      mockServer = new WebSocketServer('wss://example.com/')
+    })
 
-  after((done) => {
-    mockServer.stop(() => {
-      window.WebSocket = null
-      done()
+    after((done) => {
+      mockServer.stop(() => done())
     })
-  })
 
-  beforeEach(() => {
-    socket = new Socket("/socket", {
-      reconnectAfterMs: () => 100000
+    beforeEach(() => {
+      socket = new Socket("/socket", {
+        reconnectAfterMs: () => 100000
+      })
+      socket.connect()
     })
-    socket.connect()
-  })
 
-  it("parses raw message and triggers channel event", () => {
-    const message = encode({topic: "topic", event: "event", payload: "payload", ref: "ref"})
-    const data = {data: message}
+    it("parses raw message and triggers channel event", () => {
+      const message = encode({topic: "topic", event: "event", payload: "payload", ref: "ref"})
+      const data = {data: message}
 
-    const targetChannel = socket.channel("topic")
-    const otherChannel = socket.channel("off-topic")
+      const targetChannel = socket.channel("topic")
+      const otherChannel = socket.channel("off-topic")
 
-    const targetSpy = sinon.spy(targetChannel, "trigger")
-    const otherSpy = sinon.spy(otherChannel, "trigger")
+      const targetSpy = sinon.spy(targetChannel, "trigger")
+      const otherSpy = sinon.spy(otherChannel, "trigger")
 
-    socket.onConnMessage(data)
+      socket.onConnMessage(data)
 
-    assert.ok(targetSpy.calledWith("event", "payload", "ref"))
-    assert.equal(targetSpy.callCount, 1)
-    assert.equal(otherSpy.callCount, 0)
-  })
+      assert.ok(targetSpy.calledWith("event", "payload", "ref"))
+      assert.equal(targetSpy.callCount, 1)
+      assert.equal(otherSpy.callCount, 0)
+    })
 
-  it("triggers onMessage callback", () => {
-    const message = {"topic":"topic","event":"event","payload":"payload","ref":"ref"}
-    const spy = sinon.spy()
-    socket.onMessage(spy)
-    socket.onConnMessage({data: encode(message)})
-
-    assert.ok(spy.calledWith({
-      "topic": "topic",
-      "event": "event",
-      "payload": "payload",
-      "ref": "ref",
-      "join_ref": null
-    }))
+    it("triggers onMessage callback", () => {
+      const message = {"topic":"topic","event":"event","payload":"payload","ref":"ref"}
+      const spy = sinon.spy()
+      socket.onMessage(spy)
+      socket.onConnMessage({data: encode(message)})
+
+      assert.ok(spy.calledWith({
+        "topic": "topic",
+        "event": "event",
+        "payload": "payload",
+        "ref": "ref",
+        "join_ref": null
+      }))
+    })
   })
-})
 
-describe("custom encoder and decoder", () => {
+  describe("custom encoder and decoder", () => {
 
-  it("encodes to JSON array by default", () => {
-    socket = new Socket("/socket")
-    let payload = {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}}
+    it("encodes to JSON array by default", () => {
+      socket = new Socket("/socket")
+      let payload = {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}}
 
-    socket.encode(payload, encoded => {
-      assert.deepStrictEqual(encoded, '["1","2","topic","join",{"foo":"bar"}]')
+      socket.encode(payload, encoded => {
+        assert.deepStrictEqual(encoded, '["1","2","topic","join",{"foo":"bar"}]')
+      })
     })
-  })
 
-  it("allows custom encoding when using WebSocket transport", () => {
-    let encoder = (payload, callback) => callback("encode works")
-    socket = new Socket("/socket", {transport: WebSocket, encode: encoder})
+    it("allows custom encoding when using WebSocket transport", () => {
+      let encoder = (payload, callback) => callback("encode works")
+      socket = new Socket("/socket", {transport: WebSocket, encode: encoder})
 
-    socket.encode({foo: "bar"}, encoded => {
-      assert.deepStrictEqual(encoded, "encode works")
+      socket.encode({foo: "bar"}, encoded => {
+        assert.deepStrictEqual(encoded, "encode works")
+      })
     })
-  })
 
-  it("forces JSON encoding when using LongPoll transport", () => {
-    let encoder = (payload, callback) => callback("encode works")
-    socket = new Socket("/socket", {transport: LongPoll, encode: encoder})
-    let payload = {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}}
+    it("forces JSON encoding when using LongPoll transport", () => {
+      let encoder = (payload, callback) => callback("encode works")
+      socket = new Socket("/socket", {transport: LongPoll, encode: encoder})
+      let payload = {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}}
 
-    socket.encode(payload, encoded => {
-      assert.deepStrictEqual(encoded, '["1","2","topic","join",{"foo":"bar"}]')
+      socket.encode(payload, encoded => {
+        assert.deepStrictEqual(encoded, '["1","2","topic","join",{"foo":"bar"}]')
+      })
     })
-  })
 
-  it("decodes JSON by default", () => {
-    socket = new Socket("/socket")
-    let encoded = '["1","2","topic","join",{"foo":"bar"}]'
+    it("decodes JSON by default", () => {
+      socket = new Socket("/socket")
+      let encoded = '["1","2","topic","join",{"foo":"bar"}]'
 
-    socket.decode(encoded, decoded => {
-      assert.deepStrictEqual(decoded, {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}})
+      socket.decode(encoded, decoded => {
+        assert.deepStrictEqual(decoded, {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}})
+      })
     })
-  })
 
-  it("allows custom decoding when using WebSocket transport", () => {
-    let decoder = (payload, callback) => callback("decode works")
-    socket = new Socket("/socket", {transport: WebSocket, decode: decoder})
+    it("allows custom decoding when using WebSocket transport", () => {
+      let decoder = (payload, callback) => callback("decode works")
+      socket = new Socket("/socket", {transport: WebSocket, decode: decoder})
 
-    socket.decode("...esoteric format...", decoded => {
-      assert.deepStrictEqual(decoded, "decode works")
+      socket.decode("...esoteric format...", decoded => {
+        assert.deepStrictEqual(decoded, "decode works")
+      })
     })
-  })
 
-  it("forces JSON decoding when using LongPoll transport", () => {
-    let decoder = (payload, callback) => callback("decode works")
-    socket = new Socket("/socket", {transport: LongPoll, decode: decoder})
-    let payload = {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}}
+    it("forces JSON decoding when using LongPoll transport", () => {
+      let decoder = (payload, callback) => callback("decode works")
+      socket = new Socket("/socket", {transport: LongPoll, decode: decoder})
+      let payload = {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}}
 
-    socket.decode('["1","2","topic","join",{"foo":"bar"}]', decoded => {
-      assert.deepStrictEqual(decoded, payload)
+      socket.decode('["1","2","topic","join",{"foo":"bar"}]', decoded => {
+        assert.deepStrictEqual(decoded, payload)
+      })
     })
   })
+
 })
+window.XMLHttpRequest = sinon.useFakeXMLHttpRequest()
+window.WebSocket = WebSocket
diff --git a/lib/phoenix/test/conn_test.ex b/lib/phoenix/test/conn_test.ex
index ffd002a58..741f43549 100644
--- a/lib/phoenix/test/conn_test.ex
+++ b/lib/phoenix/test/conn_test.ex
@@ -592,9 +592,9 @@ defmodule Phoenix.ConnTest do
   by your MyApp.ErrorView.
 
   The function accepts a status either as an integer HTTP status or
-  atom, such as `404` or `:not_found`. If an error is raised, a
-  3-tuple of the wrapped response is returned matching the
-  status, headers, and body of the response:
+  atom, such as `404` or `:not_found`. The list of allowed atoms is available
+  in `Plug.Conn.Status`. If an error is raised, a 3-tuple of the wrapped
+  response is returned matching the status, headers, and body of the response:
 
       {404, [{"content-type", "text/html"} | _], "Page not found"}