]> cat aescling's git repositories - mastodon.git/commitdiff
Make the streaming API also handle websockets (because trying to get the browser...
authorEugen Rochko <eugen@zeonfederated.com>
Fri, 3 Feb 2017 23:34:31 +0000 (00:34 +0100)
committerEugen Rochko <eugen@zeonfederated.com>
Fri, 3 Feb 2017 23:34:31 +0000 (00:34 +0100)
work flawlessly was a nightmare). WARNING: This commit makes the web UI connect to the streaming API instead
of ActionCable like before. This means that if you are upgrading, you should set that up beforehand.

14 files changed:
.env.production.sample
app/assets/javascripts/application.js
app/assets/javascripts/cable.js [deleted file]
app/assets/javascripts/components/containers/mastodon.jsx
app/assets/javascripts/components/features/hashtag_timeline/index.jsx
app/assets/javascripts/components/features/public_timeline/index.jsx
app/assets/javascripts/components/stream.jsx [new file with mode: 0644]
app/views/home/index.html.haml
config/initializers/ostatus.rb
docker-compose.yml
docs/Running-Mastodon/Production-guide.md
package.json
streaming/index.js
yarn.lock

index 1a96775de0364266c871e089b8769981bd134695..ef0af9d5c2394c65910f0314172e195101b1cbb1 100644 (file)
@@ -43,5 +43,5 @@ SMTP_FROM_ADDRESS=notifications@example.com
 # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
 # S3_CLOUDFRONT_HOST=
 
-# Optional Firebase Cloud Messaging API key
-FCM_API_KEY=
+# Streaming API integration
+# STREAMING_API_BASE_URL=
index c442ded6174f1145a7b4fecb74c217e45c469835..e2fffd9320d9666287a5e42730d6c40185d51e1a 100644 (file)
@@ -13,4 +13,3 @@
 //= require jquery
 //= require jquery_ujs
 //= require components
-//= require cable
diff --git a/app/assets/javascripts/cable.js b/app/assets/javascripts/cable.js
deleted file mode 100644 (file)
index 0325876..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-// Action Cable provides the framework to deal with WebSockets in Rails.
-// You can generate new channels where WebSocket features live using the rails generate channel command.
-//
-//= require action_cable
-//= require_self
-
-(function() {
-  this.App || (this.App = {});
-
-  App.cable = ActionCable.createConsumer();
-
-}).call(this);
index 5fd43fb2b2845c9d8751300f21ea5a9502299dd4..46a01b2005f778a413e81dda67c45863ec566e3d 100644 (file)
@@ -43,6 +43,7 @@ import hu from 'react-intl/locale-data/hu';
 import uk from 'react-intl/locale-data/uk';
 import getMessagesForLocale from '../locales';
 import { hydrateStore } from '../actions/store';
+import createStream from '../stream';
 
 const store = configureStore();
 
@@ -60,28 +61,27 @@ const Mastodon = React.createClass({
     locale: React.PropTypes.string.isRequired
   },
 
-  componentWillMount() {
-    const { locale } = this.props;
-
-    if (typeof App !== 'undefined') {
-      this.subscription = App.cable.subscriptions.create('TimelineChannel', {
-
-        received (data) {
-          switch(data.event) {
-          case 'update':
-            store.dispatch(updateTimeline('home', JSON.parse(data.payload)));
-            break;
-          case 'delete':
-            store.dispatch(deleteFromTimelines(data.payload));
-            break;
-          case 'notification':
-            store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale));
-            break;
-          }
+  componentDidMount() {
+    const { locale }  = this.props;
+    const accessToken = store.getState().getIn(['meta', 'access_token']);
+
+    this.subscription = createStream(accessToken, 'user', {
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          store.dispatch(updateTimeline('home', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          store.dispatch(deleteFromTimelines(data.payload));
+          break;
+        case 'notification':
+          store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale));
+          break;
         }
+      }
 
-      });
-    }
+    });
 
     // Desktop notifications
     if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
@@ -91,7 +91,8 @@ const Mastodon = React.createClass({
 
   componentWillUnmount () {
     if (typeof this.subscription !== 'undefined') {
-      this.subscription.unsubscribe();
+      this.subscription.close();
+      this.subscription = null;
     }
   },
 
index 7548e6d56cdf4138af250b46879d94e00fcb7117..4a0e7684dceb704c5dd58672d005305f882ceb49 100644 (file)
@@ -8,45 +8,49 @@ import {
   deleteFromTimelines
 } from '../../actions/timelines';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
+
+const mapStateToProps = state => ({
+  accessToken: state.getIn(['meta', 'access_token'])
+});
 
 const HashtagTimeline = React.createClass({
 
   propTypes: {
     params: React.PropTypes.object.isRequired,
-    dispatch: React.PropTypes.func.isRequired
+    dispatch: React.PropTypes.func.isRequired,
+    accessToken: React.PropTypes.string.isRequired
   },
 
   mixins: [PureRenderMixin],
 
   _subscribe (dispatch, id) {
-    if (typeof App !== 'undefined') {
-      this.subscription = App.cable.subscriptions.create({
-        channel: 'HashtagChannel',
-        tag: id
-      }, {
-
-        received (data) {
-          switch(data.event) {
-          case 'update':
-            dispatch(updateTimeline('tag', JSON.parse(data.payload)));
-            break;
-          case 'delete':
-            dispatch(deleteFromTimelines(data.payload));
-            break;
-          }
+    const { accessToken } = this.props;
+
+    this.subscription = createStream(accessToken, `hashtag&tag=${id}`, {
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('tag', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
         }
+      }
 
-      });
-    }
+    });
   },
 
   _unsubscribe () {
     if (typeof this.subscription !== 'undefined') {
-      this.subscription.unsubscribe();
+      this.subscription.close();
+      this.subscription = null;
     }
   },
 
-  componentWillMount () {
+  componentDidMount () {
     const { dispatch } = this.props;
     const { id } = this.props.params;
 
@@ -79,4 +83,4 @@ const HashtagTimeline = React.createClass({
 
 });
 
-export default connect()(HashtagTimeline);
+export default connect(mapStateToProps)(HashtagTimeline);
index 42970061c5c8efa1eda1a3d810530321c43fcad1..36d68dbbba183c82e9bc54c681df915c55a659d2 100644 (file)
@@ -9,46 +9,51 @@ import {
 } from '../../actions/timelines';
 import { defineMessages, injectIntl } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
 
 const messages = defineMessages({
   title: { id: 'column.public', defaultMessage: 'Public' }
 });
 
+const mapStateToProps = state => ({
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
 const PublicTimeline = React.createClass({
 
   propTypes: {
     dispatch: React.PropTypes.func.isRequired,
-    intl: React.PropTypes.object.isRequired
+    intl: React.PropTypes.object.isRequired,
+    accessToken: React.PropTypes.string.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  componentWillMount () {
-    const { dispatch } = this.props;
+  componentDidMount () {
+    const { dispatch, accessToken } = this.props;
 
     dispatch(refreshTimeline('public'));
 
-    if (typeof App !== 'undefined') {
-      this.subscription = App.cable.subscriptions.create('PublicChannel', {
+    this.subscription = createStream(accessToken, 'public', {
 
-        received (data) {
-          switch(data.event) {
-          case 'update':
-            dispatch(updateTimeline('public', JSON.parse(data.payload)));
-            break;
-          case 'delete':
-            dispatch(deleteFromTimelines(data.payload));
-            break;
-          }
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('public', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
         }
+      }
 
-      });
-    }
+    });
   },
 
   componentWillUnmount () {
     if (typeof this.subscription !== 'undefined') {
-      this.subscription.unsubscribe();
+      this.subscription.close();
+      this.subscription = null;
     }
   },
 
@@ -65,4 +70,4 @@ const PublicTimeline = React.createClass({
 
 });
 
-export default connect()(injectIntl(PublicTimeline));
+export default connect(mapStateToProps)(injectIntl(PublicTimeline));
diff --git a/app/assets/javascripts/components/stream.jsx b/app/assets/javascripts/components/stream.jsx
new file mode 100644 (file)
index 0000000..0787399
--- /dev/null
@@ -0,0 +1,21 @@
+import WebSocketClient from 'websocket.js';
+
+const createWebSocketURL = (url) => {
+  const a = document.createElement('a');
+
+  a.href     = url;
+  a.href     = a.href;
+  a.protocol = a.protocol.replace('http', 'ws');
+
+  return a.href;
+};
+
+export default function getStream(accessToken, stream, { connected, received, disconnected }) {
+  const ws = new WebSocketClient(`${createWebSocketURL(STREAMING_API_BASE_URL)}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
+
+  ws.onopen    = connected;
+  ws.onmessage = e => received(JSON.parse(e.data));
+  ws.onclose   = disconnected;
+
+  return ws;
+};
index 0147f4064b943e299337e7216ba2457973212541..9e3b9446335813d016d32fa20deecad5f2e9410f 100644 (file)
@@ -1,5 +1,6 @@
 - content_for :header_tags do
   :javascript
+    window.STREAMING_API_BASE_URL = '#{Rails.configuration.x.streaming_api_base_url}';
     window.INITIAL_STATE = #{json_escape(render(file: 'home/initial_state', formats: :json))}
 
   = javascript_include_tag 'application'
index faa9940b0b22c2a2a6e331b5b2f33a7d3223959b..fb0b8b7fe328417f4935a3e488df9657a6559a7a 100644 (file)
@@ -10,8 +10,10 @@ Rails.application.configure do
   config.x.use_s3       = ENV['S3_ENABLED'] == 'true'
 
   config.action_mailer.default_url_options = { host: host, protocol: https ? 'https://' : 'http://', trailing_slash: false }
+  config.x.streaming_api_base_url          = 'http://localhost:4000'
 
   if Rails.env.production?
     config.action_cable.allowed_request_origins = ["http#{https ? 's' : ''}://#{host}"]
+    config.x.streaming_api_base_url             = ENV.fetch('STREAMING_API_BASE_URL') { "http#{https ? 's' : ''}://#{host}" }
   end
 end
index e1f1f1c4cdedd2bfabfbab5d728c8b0a3d0f6199..e6002eaa5e3a5a3d78db9c4788614a08cd4d2b64 100644 (file)
@@ -19,6 +19,16 @@ services:
     volumes:
       - ./public/assets:/mastodon/public/assets
       - ./public/system:/mastodon/public/system
+  streaming:
+    restart: always
+    build: .
+    env_file: .env.production
+    command: npm run start
+    ports:
+      - "4000:4000"
+    depends_on:
+      - db
+      - redis
   sidekiq:
     restart: always
     build: .
index 76964d9953134b214af1d0c4d88c4bf204db65f1..ff4427dd2846b8581861160d570f6d794d1e78a3 100644 (file)
@@ -49,6 +49,22 @@ server {
     tcp_nodelay on;
   }
 
+  location /api/v1/streaming {
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto https;
+
+    proxy_pass http://localhost:4000;
+    proxy_buffering off;
+    proxy_redirect off;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection $connection_upgrade;
+
+    tcp_nodelay on;
+  }
+
   error_page 500 501 502 503 504 /500.html;
 }
 ```
@@ -162,6 +178,27 @@ Restart=always
 WantedBy=multi-user.target
 ```
 
+Example systemd configuration file for the streaming API, to be placed in `/etc/systemd/system/mastodon-streaming.service`:
+
+```systemd
+[Unit]
+Description=mastodon-streaming
+After=network.target
+
+[Service]
+Type=simple
+User=mastodon
+WorkingDirectory=/home/mastodon/live
+Environment="NODE_ENV=production"
+Environment="PORT=4000"
+ExecStart=/usr/bin/npm run start
+TimeoutSec=15
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
+```
+
 This allows you to `sudo systemctl enable mastodon-*.service` and `sudo systemctl start mastodon-*.service` to get things going.
 
 ## Cronjobs
index 9685f07a46b829cabbfbf93f4be56ef6dfed2c4c..def42f5960710a04d9403c3d1d3f1deb62377b5c 100644 (file)
@@ -18,6 +18,7 @@
     "babelify": "^7.3.0",
     "browserify": "^13.1.0",
     "browserify-incremental": "^3.1.1",
+    "bufferutil": "^2.0.0",
     "chai": "^3.5.0",
     "chai-enzyme": "^0.5.2",
     "css-loader": "^0.26.1",
@@ -64,6 +65,9 @@
     "sass-loader": "^4.0.2",
     "sinon": "^1.17.6",
     "style-loader": "^0.13.1",
-    "webpack": "^1.14.0"
+    "utf-8-validate": "^3.0.0",
+    "webpack": "^1.14.0",
+    "websocket.js": "^0.1.7",
+    "ws": "^2.0.2"
   }
 }
index e5a2778f8fd09a3e907a30e32a3fb41a96cd5fb6..16dda5c1e77e45f2d1681d64ea7495638ebfb2f0 100644 (file)
@@ -1,8 +1,11 @@
 import dotenv from 'dotenv'
 import express from 'express'
+import http from 'http'
 import redis from 'redis'
 import pg from 'pg'
 import log from 'npmlog'
+import url from 'url'
+import WebSocket from 'ws'
 
 const env = process.env.NODE_ENV || 'development'
 
@@ -27,8 +30,10 @@ const pgConfigs = {
   }
 }
 
-const app = express()
+const app    = express()
 const pgPool = new pg.Pool(pgConfigs[env])
+const server = http.createServer(app)
+const wss    = new WebSocket.Server({ server })
 
 const allowCrossDomain = (req, res, next) => {
   res.header('Access-Control-Allow-Origin', '*')
@@ -38,22 +43,7 @@ const allowCrossDomain = (req, res, next) => {
   next()
 }
 
-const authenticationMiddleware = (req, res, next) => {
-  if (req.method === 'OPTIONS') {
-    return next()
-  }
-
-  const authorization = req.get('Authorization')
-
-  if (!authorization) {
-    const err = new Error('Missing access token')
-    err.statusCode = 401
-
-    return next(err)
-  }
-
-  const token = authorization.replace(/^Bearer /, '')
-
+const accountFromToken = (token, req, next) => {
   pgPool.connect((err, client, done) => {
     if (err) {
       return next(err)
@@ -80,26 +70,36 @@ const authenticationMiddleware = (req, res, next) => {
   })
 }
 
+const authenticationMiddleware = (req, res, next) => {
+  if (req.method === 'OPTIONS') {
+    return next()
+  }
+
+  const authorization = req.get('Authorization')
+
+  if (!authorization) {
+    const err = new Error('Missing access token')
+    err.statusCode = 401
+
+    return next(err)
+  }
+
+  const token = authorization.replace(/^Bearer /, '')
+
+  accountFromToken(token, req, next)
+}
+
 const errorMiddleware = (err, req, res, next) => {
   log.error(err)
   res.writeHead(err.statusCode || 500, { 'Content-Type': 'application/json' })
-  res.end(JSON.stringify({ error: err.statusCode ? `${err}` : 'An unexpected error occured' }))
+  res.end(JSON.stringify({ error: err.statusCode ? `${err}` : 'An unexpected error occurred' }))
 }
 
 const placeholders = (arr, shift = 0) => arr.map((_, i) => `$${i + 1 + shift}`).join(', ');
 
-const streamFrom = (id, req, res, needsFiltering = false) => {
+const streamFrom = (redisClient, id, req, output, needsFiltering = false) => {
   log.verbose(`Starting stream from ${id} for ${req.accountId}`)
 
-  res.setHeader('Content-Type', 'text/event-stream')
-  res.setHeader('Transfer-Encoding', 'chunked')
-
-  const redisClient = redis.createClient({
-    host:     process.env.REDIS_HOST     || '127.0.0.1',
-    port:     process.env.REDIS_PORT     || 6379,
-    password: process.env.REDIS_PASSWORD
-  })
-
   redisClient.on('message', (channel, message) => {
     const { event, payload } = JSON.parse(message)
 
@@ -127,36 +127,107 @@ const streamFrom = (id, req, res, needsFiltering = false) => {
             return
           }
 
-          res.write(`event: ${event}\n`)
-          res.write(`data: ${payload}\n\n`)
+          log.silly(`Transmitting for ${req.accountId}: ${event} ${payload}`)
+          output(event, payload)
         })
       })
     } else {
-      res.write(`event: ${event}\n`)
-      res.write(`data: ${payload}\n\n`)
+      log.silly(`Transmitting for ${req.accountId}: ${event} ${payload}`)
+      output(event, payload)
     }
   })
 
+  redisClient.subscribe(id)
+}
+
+// Setup stream output to HTTP
+const streamToHttp = (req, res, redisClient) => {
+  res.setHeader('Content-Type', 'text/event-stream')
+  res.setHeader('Transfer-Encoding', 'chunked')
+
   const heartbeat = setInterval(() => res.write(':thump\n'), 15000)
 
   req.on('close', () => {
-    log.verbose(`Ending stream from ${id} for ${req.accountId}`)
+    log.verbose(`Ending stream for ${req.accountId}`)
     clearInterval(heartbeat)
     redisClient.quit()
   })
 
-  redisClient.subscribe(id)
+  return (event, payload) => {
+    res.write(`event: ${event}\n`)
+    res.write(`data: ${payload}\n\n`)
+  }
+}
+
+// Setup stream output to WebSockets
+const streamToWs = (req, ws, redisClient) => {
+  ws.on('close', () => {
+    log.verbose(`Ending stream for ${req.accountId}`)
+    redisClient.quit()
+  })
+
+  return (event, payload) => {
+    ws.send(JSON.stringify({ event, payload }))
+  }
 }
 
+// Get new redis connection
+const getRedisClient = () => redis.createClient({
+  host:     process.env.REDIS_HOST     || '127.0.0.1',
+  port:     process.env.REDIS_PORT     || 6379,
+  password: process.env.REDIS_PASSWORD
+})
+
 app.use(allowCrossDomain)
 app.use(authenticationMiddleware)
 app.use(errorMiddleware)
 
-app.get('/api/v1/streaming/user',    (req, res) => streamFrom(`timeline:${req.accountId}`, req, res))
-app.get('/api/v1/streaming/public',  (req, res) => streamFrom('timeline:public', req, res, true))
-app.get('/api/v1/streaming/hashtag', (req, res) => streamFrom(`timeline:hashtag:${req.params.tag}`, req, res, true))
+app.get('/api/v1/streaming/user', (req, res) => {
+  const redisClient = getRedisClient()
+  streamFrom(redisClient, `timeline:${req.accountId}`, req, streamToHttp(req, res, redisClient))
+})
+
+app.get('/api/v1/streaming/public', (req, res) => {
+  const redisClient = getRedisClient()
+  streamFrom(redisClient, 'timeline:public', req, streamToHttp(req, res, redisClient), true)
+})
+
+app.get('/api/v1/streaming/hashtag', (req, res) => {
+  const redisClient = getRedisClient()
+  streamFrom(redisClient, `timeline:hashtag:${req.params.tag}`, req, streamToHttp(req, res, redisClient), true)
+})
 
-log.level = 'verbose'
-log.info(`Starting HTTP server on port ${process.env.PORT || 4000}`)
+wss.on('connection', ws => {
+  const location = url.parse(ws.upgradeReq.url, true)
+  const token    = location.query.access_token
+  const req      = {}
 
-app.listen(process.env.PORT || 4000)
+  accountFromToken(token, req, err => {
+    if (err) {
+      log.error(err)
+      ws.close()
+      return
+    }
+
+    const redisClient = getRedisClient()
+
+    switch(location.query.stream) {
+    case 'user':
+      streamFrom(redisClient, `timeline:${req.accountId}`, req, streamToWs(req, ws, redisClient))
+      break;
+    case 'public':
+      streamFrom(redisClient, 'timeline:public', req, streamToWs(req, ws, redisClient), true)
+      break;
+    case 'hashtag':
+      streamFrom(redisClient, `timeline:hashtag:${location.query.tag}`, req, streamToWs(req, ws, redisClient), true)
+      break;
+    default:
+      ws.close()
+    }
+  })
+})
+
+server.listen(process.env.PORT || 4000, () => {
+  log.level = process.env.LOG_LEVEL || 'verbose'
+  log.info(`Starting streaming API server on port ${server.address().port}`)
+})
index bd174792944d3d4ce00fd64af6da75701adcd1cf..8038411fef16f6e7130f74d8ec7074b960abe918 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -1237,6 +1237,12 @@ babylon@^6.15.0:
   version "6.15.0"
   resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.15.0.tgz#ba65cfa1a80e1759b0e89fb562e27dccae70348e"
 
+backoff@^2.4.1:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/backoff/-/backoff-2.5.0.tgz#f616eda9d3e4b66b8ca7fca79f695722c5f8e26f"
+  dependencies:
+    precond "0.2"
+
 balanced-match@^0.4.1, balanced-match@^0.4.2:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838"
@@ -1263,6 +1269,10 @@ binary-extensions@^1.0.0:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.7.0.tgz#6c1610db163abfb34edfe42fa423343a1e01185d"
 
+bindings@~1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.2.1.tgz#14ad6113812d2d37d72e67b4cacb4bb726505f11"
+
 bl@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/bl/-/bl-1.1.2.tgz#fdca871a99713aa00d19e3bbba41c44787a65398"
@@ -1479,6 +1489,13 @@ buffer@^4.1.0, buffer@^4.9.0:
     ieee754 "^1.1.4"
     isarray "^1.0.0"
 
+bufferutil@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-2.0.0.tgz#6588ed4bafa300798b26dc048494a51abde83507"
+  dependencies:
+    bindings "~1.2.1"
+    nan "~2.5.0"
+
 builtin-modules@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
@@ -3664,9 +3681,9 @@ ms@0.7.2:
   version "0.7.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765"
 
-nan@^2.3.0, nan@^2.3.2:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.4.0.tgz#fb3c59d45fe4effe215f0b890f8adf6eb32d2232"
+nan@^2.3.0, nan@^2.3.2, nan@~2.5.0:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.1.tgz#d5b01691253326a97a2bbee9e61c55d8d60351e2"
 
 negotiator@0.6.1:
   version "0.6.1"
@@ -3808,16 +3825,7 @@ normalize-url@^1.4.0:
     gauge "~2.6.0"
     set-blocking "~2.0.0"
 
-npmlog@4.x, npmlog@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.0.tgz#e094503961c70c1774eb76692080e8d578a9f88f"
-  dependencies:
-    are-we-there-yet "~1.1.2"
-    console-control-strings "~1.1.0"
-    gauge "~2.6.0"
-    set-blocking "~2.0.0"
-
-npmlog@^4.0.2:
+npmlog@4.x, npmlog@^4.0.0, npmlog@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.2.tgz#d03950e0e78ce1527ba26d2a7592e9348ac3e75f"
   dependencies:
@@ -4401,6 +4409,10 @@ postgres-interval@~1.0.0:
   dependencies:
     xtend "^4.0.0"
 
+precond@0.2:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac"
+
 prelude-ls@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@@ -5556,6 +5568,10 @@ uid-number@~0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
 
+ultron@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.0.tgz#b07a2e6a541a815fc6a34ccd4533baec307ca864"
+
 umd@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/umd/-/umd-3.0.1.tgz#8ae556e11011f63c2596708a8837259f01b3d60e"
@@ -5603,6 +5619,13 @@ user-home@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190"
 
+utf-8-validate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-3.0.0.tgz#42e54dfbc7cdfbd1d3bbf0a2f5000b4c6aeaa0c9"
+  dependencies:
+    bindings "~1.2.1"
+    nan "~2.5.0"
+
 util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -5727,6 +5750,12 @@ webpack@^1.13.1, webpack@^1.14.0:
     watchpack "^0.2.1"
     webpack-core "~0.6.9"
 
+websocket.js@^0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/websocket.js/-/websocket.js-0.1.7.tgz#8d24cefb1a080c259e7e4740c02cab8f142df2b0"
+  dependencies:
+    backoff "^2.4.1"
+
 whatwg-fetch@>=0.10.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-1.0.0.tgz#01c2ac4df40e236aaa18480e3be74bd5c8eb798e"
@@ -5803,6 +5832,12 @@ write-file-atomic@^1.1.2:
     imurmurhash "^0.1.4"
     slide "^1.1.5"
 
+ws@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-2.0.2.tgz#6257d1a679f0cb23658cba3dcad1316e2b1000c5"
+  dependencies:
+    ultron "~1.1.0"
+
 xdg-basedir@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"