Repository: twitch-apis/twitch-js (js)

Issue:
V2: IRC parse errors crash the client
## Steps to reproduce

It seems a message like this causes a `PARSE_ERROR_ENCOUNTERED` (newlines included as-is): 

```
":kumpelblase2!kumpelblase2@kumpelblase2.tmi.twitch.tv JOIN #k3nt0456
:jtv MODE #k3nt0456 +o k3nt0456
"
```

`eventName` is then undefined at `emit`. Skimming through the code, it looks like `eventName` should be `*`

Screenie for more context: 

![](https://puu.sh/B0R0b/a8061e9b20.png)


## Your environment

* Operating system(s): Electron on Win 10

---

Thanks for the work, I much prefer the v2 API so far.   
Let me know if you want a PR, though I haven't tried debugging yet



Pull request:
Feature: handle reconnect
Resolves #106

Patch:
diff --git a/README.md b/README.md
index 3ed91c2..c2216f6 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ accordingly to avoid regressions.
 
 * Aligns with official
   [Twitch IRC documentation](https://dev.twitch.tv/docs/irc/)
-* Forward-compatible, minimally-assertive architecture
+* Forward-compatible, low-level, minimally-assertive architecture
 * Supports Node environments
 * Supports Browsers
 * Connect to multiple channels
diff --git a/__mocks__/ws/index.js b/__mocks__/ws/index.js
index 3f28474..d854c2a 100644
--- a/__mocks__/ws/index.js
+++ b/__mocks__/ws/index.js
@@ -13,7 +13,10 @@ class WebSocket extends EventEmitter {
     super()
 
     // Emit `open` event on next event loop.
-    setTimeout(() => this.emit('open'))
+    setTimeout(() => {
+      server.emit('open')
+      this.emit('open')
+    })
 
     this.readyState = 2
   }
diff --git a/docs/reference/class.md b/docs/reference/class.md
index 3b3b2e2..d5a5f66 100644
--- a/docs/reference/class.md
+++ b/docs/reference/class.md
@@ -213,6 +213,7 @@ Chat client
     * [.connect()](#Chat+connect) ⇒ <code>Promise.&lt;GlobalUserStateMessage, string&gt;</code>
     * [.send(message)](#Chat+send)
     * [.disconnect()](#Chat+disconnect)
+    * [.reconnect()](#Chat+reconnect)
     * [.join(channel)](#Chat+join) ⇒ <code>Promise.&lt;ChannelState, string&gt;</code>
     * [.part(channel)](#Chat+part)
     * [.say(channel, message)](#Chat+say) ⇒ <code>Promise.&lt;UserStateMessage, string&gt;</code>
@@ -380,7 +381,16 @@ Sends a raw message to Twitch.
 <a name="Chat+disconnect"></a>
 
 ### chat.disconnect()
-Disconnect from Twitch.
+Disconnected from Twitch.
+
+**Kind**: instance method of [<code>Chat</code>](#Chat)  
+
+* * *
+
+<a name="Chat+reconnect"></a>
+
+### chat.reconnect()
+Reconnect to Twitch.
 
 **Kind**: instance method of [<code>Chat</code>](#Chat)  
 
diff --git a/src/Chat/Client.js b/src/Chat/Client.js
index ee414a1..75a41b8 100644
--- a/src/Chat/Client.js
+++ b/src/Chat/Client.js
@@ -13,6 +13,9 @@ const priority = constants.CLIENT_PRIORITY
 class Client extends EventEmitter {
   readyState
 
+  pingTimer = null
+  reconnectTimer = null
+
   constructor(maybeOptions = {}) {
     super()
 
@@ -54,6 +57,7 @@ class Client extends EventEmitter {
   }
 
   disconnect() {
+    handleKeepAliveReset.call(this)
     this.ws.close()
   }
 }
@@ -70,10 +74,11 @@ function handleOpen(options) {
 }
 
 function handleMessage(messageEvent) {
-  // console.log(messageEvent.data)
   const rawMessage = messageEvent.data
 
   try {
+    handleKeepAlive.call(this)
+
     const messages = baseParser(rawMessage)
 
     messages.forEach(message => {
@@ -90,6 +95,14 @@ function handleMessage(messageEvent) {
         })
       }
 
+      // Handle RECONNECT.
+      if (message.command === constants.COMMANDS.RECONNECT) {
+        this.emit(constants.EVENTS.RECONNECT, {
+          ...message,
+          command: constants.EVENTS.RECONNECT,
+        })
+      }
+
       // Emit all messages.
       this.emit(constants.EVENTS.ALL, message)
     })
@@ -98,6 +111,7 @@ function handleMessage(messageEvent) {
       _raw: rawMessage,
       timestamp: new Date(),
       event: constants.EVENTS.PARSE_ERROR_ENCOUNTERED,
+      command: constants.EVENTS.PARSE_ERROR_ENCOUNTERED,
       message: error,
     }
 
@@ -134,4 +148,25 @@ function handleClose() {
   this.emit(constants.EVENTS.DISCONNECTED, message)
 }
 
+function handleKeepAlive() {
+  handleKeepAliveReset.call(this)
+
+  this.pingTimer = setTimeout(
+    () => this.send(constants.COMMANDS.PING, { priority }),
+    constants.KEEP_ALIVE_PING_TIMEOUT,
+  )
+
+  this.reconnectTimer = setTimeout(
+    () => this.emit(constants.EVENTS.RECONNECT, {}),
+    constants.KEEP_ALIVE_RECONNECT_TIMEOUT,
+  )
+}
+
+function handleKeepAliveReset() {
+  clearTimeout(this.pingTimer)
+  clearTimeout(this.reconnectTimer)
+  this.pingTimer = null
+  this.reconnectTimer = null
+}
+
 export default Client
diff --git a/src/Chat/Queue.js b/src/Chat/Queue.js
index 8da0828..64200f7 100644
--- a/src/Chat/Queue.js
+++ b/src/Chat/Queue.js
@@ -25,7 +25,7 @@ class Queue extends BetterQueue {
 
     // Start rate-limiter burn down.
     this.rateLimiterIntervalId = setInterval(
-      this.burnDownRateLimiter,
+      this.burnDownRateLimiter.bind(this),
       constants.QUEUE_TICK_RATE,
     )
   }
diff --git a/src/Chat/constants.js b/src/Chat/constants.js
index 7109fd4..706ae2b 100644
--- a/src/Chat/constants.js
+++ b/src/Chat/constants.js
@@ -8,6 +8,8 @@ export const CHAT_SERVER_SSL_PORT = 443
  * @default 5000
  */
 export const CONNECTION_TIMEOUT = 5000
+export const KEEP_ALIVE_PING_TIMEOUT = 55000
+export const KEEP_ALIVE_RECONNECT_TIMEOUT = 60000
 /**
  * @constant
  * @type {number}
diff --git a/src/Chat/index.js b/src/Chat/index.js
index d27028b..0e7f5f8 100644
--- a/src/Chat/index.js
+++ b/src/Chat/index.js
@@ -123,6 +123,8 @@ class Chat extends EventEmitter {
         // Create client and connect.
         const client = new Client(this.options)
 
+        client.removeAllListeners()
+
         // Once the client is connected ...
         client.once(constants.EVENTS.CONNECTED, globalUserStateMessage => {
           this.readyState = 2
@@ -130,8 +132,33 @@ class Chat extends EventEmitter {
           // Create commands.
           Object.assign(this, commandsFactory.call(this))
 
-          this.send = this.send.bind(this, client)
-          this.disconnect = this.disconnect.bind(this, client)
+          /**
+           * Sends a raw message to Twitch.
+           * @param {string} message - Message to send.
+           */
+          this.send = message => client.send(message)
+
+          /**
+           * Disconnected from Twitch.
+           */
+          this.disconnect = () => {
+            client.disconnect()
+            this.readyState = 4
+            this.userState = {}
+            this.channels = {}
+          }
+
+          /**
+           * Reconnect to Twitch.
+           */
+          this.reconnect = () => {
+            const channels = Object.keys(this.channels)
+            this.disconnect()
+
+            return this.connect().then(() =>
+              Promise.all(channels.map(channel => this.join(channel))),
+            )
+          }
 
           // Bind events.
           client.on(constants.EVENTS.ALL, handleMessage, this)
@@ -139,6 +166,10 @@ class Chat extends EventEmitter {
           // Listen for disconnect.
           client.once(constants.EVENTS.DISCONNECTED, this.disconnect)
 
+          // Listen for reconnects.
+          client.once(constants.EVENTS.RECONNECT, this.reconnect)
+
+          // Process GLOBALUSERSTATE message.
           handleMessage.call(this, globalUserStateMessage)
 
           // ... resolve.
@@ -156,28 +187,6 @@ class Chat extends EventEmitter {
     ])
   }
 
-  /**
-   * Sends a raw message to Twitch.
-   * @param {string} message - Message to send.
-   */
-  send(client, message) {
-    client.send(message)
-  }
-
-  /**
-   * Disconnect from Twitch.
-   */
-  disconnect(client) {
-    this.readyState = 3
-
-    client.removeAllListeners()
-
-    this.userState = {}
-    this.channels = {}
-
-    this.readyState = 4
-  }
-
   /**
    * Join a channel.
    * @param {string} channel
@@ -297,13 +306,14 @@ class Chat extends EventEmitter {
     )
   }
 
-  /** @private */
   emit(eventName, message) {
-    eventName.split('/').reduce((parents, current) => {
-      const eventPartial = [...parents, current]
-      super.emit(eventPartial.join('/'), message)
-      return eventPartial
-    }, [])
+    if (eventName) {
+      eventName.split('/').reduce((parents, current) => {
+        const eventPartial = [...parents, current]
+        super.emit(eventPartial.join('/'), message)
+        return eventPartial
+      }, [])
+    }
 
     /**
      * All events are also emitted with this event name.
@@ -321,7 +331,7 @@ function handleMessage(baseMessage) {
     [channel, 'userState', 'displayName'],
     '',
   )
-  const messageDisplayName = get(baseMessage, 'state.displayName')
+  const messageDisplayName = get(baseMessage, 'tags.displayName')
   const isSelf = displayName === messageDisplayName
 
   const preMessage = { ...baseMessage, isSelf }
@@ -334,7 +344,6 @@ function handleMessage(baseMessage) {
     }
     case constants.EVENTS.PART: {
       const message = parsers.joinOrPartMessage(preMessage)
-      this.channels[message.channel] = undefined
       this.emit(`${message.command}/${channel}`, message)
       break
     }
diff --git a/src/Chat/utils/parsers/index.js b/src/Chat/utils/parsers/index.js
index 7fe67f5..dab7b47 100644
--- a/src/Chat/utils/parsers/index.js
+++ b/src/Chat/utils/parsers/index.js
@@ -8,7 +8,10 @@ import * as typeParsers from './types'
 import * as tagParsers from './tags'
 
 const base = rawMessages => {
-  const rawMessagesV = rawMessages.replace(/[\r\n]+$/g, '').split(/\n/g)
+  const rawMessagesV = rawMessages
+    .replace(/[\r\n]+/g, '\n')
+    .replace(/\n+$/g, '')
+    .split(/\n/g)
 
   return rawMessagesV.map(rawMessage => {
     const { raw, tags, command, params: [channel, message] } = parse(rawMessage)
diff --git a/src/Chat/utils/parsers/types.js b/src/Chat/utils/parsers/types.js
index adcb3f9..b01fbd5 100644
--- a/src/Chat/utils/parsers/types.js
+++ b/src/Chat/utils/parsers/types.js
@@ -1,6 +1,10 @@
 import { gt, isFinite, replace } from 'lodash'
 
-const generalString = maybeMessage => replace(maybeMessage, /\\s/g, ' ')
+const generalString = maybeMessage => {
+  return typeof maybeMessage === 'string'
+    ? replace(maybeMessage, /\\s/g, ' ')
+    : undefined
+}
 
 const generalNumber = maybeNumber => {
   const number = parseInt(maybeNumber, 10)


Test patch:
diff --git a/src/Chat/__tests__/Client.test.js b/src/Chat/__tests__/Client.test.js
index 7d22de5..e7c45de 100644
--- a/src/Chat/__tests__/Client.test.js
+++ b/src/Chat/__tests__/Client.test.js
@@ -56,11 +56,47 @@ describe('Chat/Client', () => {
     new Client(options)
 
     server.once('message', message => {
-      // console.log(message)
       expect(message).toEqual(membership.PONG)
       done()
     })
 
     server.sendMessageToClient(membership.PING)
   })
+
+  describe('keep alive', () => {
+    afterAll(() => {
+      jest.useRealTimers()
+    })
+
+    test('should send PING after keep alive delay', done => {
+      jest.useFakeTimers()
+
+      server.on('message', message => {
+        if (message === constants.COMMANDS.PING) {
+          done()
+          server.off('message')
+        }
+      })
+
+      const client = new Client(options)
+      jest.advanceTimersByTime(1000)
+
+      client.on(constants.EVENTS.CONNECTED, () =>
+        jest.advanceTimersByTime(constants.KEEP_ALIVE_PING_TIMEOUT),
+      )
+    })
+
+    test('should emit RECONNECT after keep alive expires', done => {
+      jest.useFakeTimers()
+
+      const client = new Client(options)
+      jest.advanceTimersByTime(1000)
+
+      client.on(constants.EVENTS.RECONNECT, () => done())
+
+      client.on(constants.EVENTS.CONNECTED, () =>
+        jest.advanceTimersByTime(constants.KEEP_ALIVE_RECONNECT_TIMEOUT),
+      )
+    })
+  })
 })
diff --git a/src/Chat/__tests__/__snapshots__/index.test.js.snap b/src/Chat/__tests__/__snapshots__/index.test.js.snap
index b1c6cd6..ff6cd83 100644
--- a/src/Chat/__tests__/__snapshots__/index.test.js.snap
+++ b/src/Chat/__tests__/__snapshots__/index.test.js.snap
@@ -843,7 +843,7 @@ Object {
   "isSelf": false,
   "tags": Object {
     "banDuration": 600,
-    "banReason": "",
+    "banReason": undefined,
     "roomId": "19571641",
     "targetUserId": "200817980",
     "tmiSentTs": "1531857100477",
@@ -897,3 +897,149 @@ Object {
   },
 }
 `;
+
+exports[`Chat should reconnect and rejoin channels 1`] = `
+Array [
+  Array [
+    "close",
+  ],
+  Array [
+    "open",
+  ],
+  Array [
+    "CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership",
+  ],
+  Array [
+    "PASS oauth:TOKEN",
+  ],
+  Array [
+    "NICK USERNAME",
+  ],
+  Array [
+    Object {
+      "_raw": "@color=#0D4200;display-name=dallas;emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;turbo=0;user-id=1337;user-type=admin :tmi.twitch.tv GLOBALUSERSTATE",
+      "channel": undefined,
+      "command": "CONNECTED",
+      "isSelf": false,
+      "message": undefined,
+      "tags": Object {
+        "color": "#0D4200",
+        "displayName": "dallas",
+        "emoteSets": "0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239",
+        "turbo": "0",
+        "userId": "1337",
+        "userType": "admin",
+      },
+      "timestamp": 2018-01-01T00:00:00.000Z,
+    },
+  ],
+  Array [
+    Object {
+      "_raw": "@color=#0D4200;display-name=dallas;emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;turbo=0;user-id=1337;user-type=admin :tmi.twitch.tv GLOBALUSERSTATE",
+      "channel": undefined,
+      "command": "GLOBALUSERSTATE",
+      "isSelf": false,
+      "message": undefined,
+      "tags": Object {
+        "color": "#0D4200",
+        "displayName": "dallas",
+        "emoteSets": Array [
+          "0",
+          "33",
+          "50",
+          "237",
+          "793",
+          "2126",
+          "3517",
+          "4578",
+          "5569",
+          "9400",
+          "10337",
+          "12239",
+        ],
+        "isTurboSubscriber": false,
+        "turbo": "0",
+        "userId": "1337",
+        "userType": "admin",
+        "username": "dallas",
+      },
+      "timestamp": 2018-01-01T00:00:00.000Z,
+    },
+  ],
+  Array [
+    "JOIN #dallas",
+  ],
+  Array [
+    Object {
+      "_raw": ":ronni!ronni@ronni.tmi.twitch.tv JOIN #dallas",
+      "channel": "#dallas",
+      "command": "JOIN",
+      "isSelf": false,
+      "message": undefined,
+      "tags": undefined,
+      "timestamp": 2018-01-01T00:00:00.000Z,
+      "username": "ronni",
+    },
+  ],
+  Array [
+    Object {
+      "_raw": "@broadcaster-lang=en;r9k=0;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #dallas",
+      "channel": "#dallas",
+      "command": "ROOMSTATE",
+      "isSelf": false,
+      "message": undefined,
+      "tags": Object {
+        "broadcasterLang": "en",
+        "broadcasterLanguage": "en",
+        "isEmoteOnly": false,
+        "isFollowersOnly": false,
+        "isR9kEnabled": false,
+        "isSubscribersOnly": false,
+        "r9k": "0",
+        "slow": "0",
+        "slowDelay": 0,
+        "subsOnly": "0",
+      },
+      "timestamp": 2018-01-01T00:00:00.000Z,
+    },
+  ],
+  Array [
+    Object {
+      "_raw": "@color=#0D4200;display-name=ronni;emote-sets=0,33,50,237,793,2126,3517,4578,5569,9400,10337,12239;mod=1;subscriber=1;turbo=1;user-type=staff :tmi.twitch.tv USERSTATE #dallas",
+      "channel": "#dallas",
+      "command": "USERSTATE",
+      "isSelf": false,
+      "message": undefined,
+      "tags": Object {
+        "badges": Object {},
+        "bits": undefined,
+        "color": "#0D4200",
+        "displayName": "ronni",
+        "emoteSets": Array [
+          "0",
+          "33",
+          "50",
+          "237",
+          "793",
+          "2126",
+          "3517",
+          "4578",
+          "5569",
+          "9400",
+          "10337",
+          "12239",
+        ],
+        "emotes": Object {},
+        "isModerator": true,
+        "isSubscriber": true,
+        "isTurboSubscriber": true,
+        "mod": "1",
+        "subscriber": "1",
+        "turbo": "1",
+        "userType": "staff",
+      },
+      "timestamp": 2018-01-01T00:00:00.000Z,
+    },
+  ],
+]
+`;
diff --git a/src/Chat/__tests__/index.test.js b/src/Chat/__tests__/index.test.js
index 2e2e83e..5fbf794 100644
--- a/src/Chat/__tests__/index.test.js
+++ b/src/Chat/__tests__/index.test.js
@@ -27,13 +27,14 @@ describe('Chat', () => {
     realDate = global.Date
     const DATE_TO_USE = new Date('2018')
     global.Date = jest.fn(() => DATE_TO_USE)
+  })
 
+  beforeEach(() => {
     chat = new Chat(options)
-    return Promise.all([chat.connect()])
+    return chat.connect()
   })
 
   afterEach(() => {
-    // server.removeAllListeners()
     chat.removeAllListeners()
   })
 
@@ -58,6 +59,47 @@ describe('Chat', () => {
     chat.say('#dallas', 'Kappa Keepo Kappa')
   })
 
+  test('should part a channel', done => {
+    expect.assertions(1)
+
+    server.once('message', message => {
+      expect(message).toEqual('PART #dallas')
+      done()
+    })
+
+    chat.part('#dallas')
+  })
+
+  test('should disconnect', done => {
+    server.once('close', () => done())
+
+    chat.disconnect()
+  })
+
+  test('should reconnect and rejoin channels', async () => {
+    await chat.join('#dallas')
+
+    const listener = jest.fn()
+    server.on('close', () => listener('close'))
+    server.on('open', () => listener('open'))
+    server.on('message', listener)
+    chat.on('*', listener)
+
+    await chat.reconnect()
+
+    expect(listener.mock.calls).toMatchSnapshot()
+
+    server.removeListener('close')
+    server.removeListener('open')
+    server.removeListener('message')
+  })
+
+  test('should reconnect on RECONNECT event', done => {
+    chat.once('CONNECTED', () => done())
+
+    server.sendMessageToClient('RECONNECT')
+  })
+
   describe('should handle messages', () => {
     test('JOIN', done => {
       chat.once(constants.EVENTS.JOIN, message => {
@@ -207,17 +249,6 @@ describe('Chat', () => {
     })
   })
 
-  test('should part a channel', done => {
-    expect.assertions(1)
-
-    server.once('message', message => {
-      expect(message).toEqual('PART #dallas')
-      done()
-    })
-
-    chat.part('#dallas')
-  })
-
   describe('should handle multiple channels', () => {
     // test('should join multiple channels', () => {})
     // test('should broadcast message to all channels', () => {})