commit: ccb8ac857330e1ad3aee37b340e5c6e242ac1dd6
parent: 8c0bc1309fd40807cf5895b492fc7d1a2c9d7b83
Author: Eugen Rochko <eugen@zeonfederated.com>
Date: Sat, 4 Feb 2017 00:34:31 +0100
Make the streaming API also handle websockets (because trying to get the browser EventSource interface to
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.
Diffstat:
14 files changed, 307 insertions(+), 129 deletions(-)
diff --git a/.env.production.sample b/.env.production.sample
@@ -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=
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
@@ -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
@@ -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);
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -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;
}
},
diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
@@ -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);
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -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
@@ -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;
+};
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
@@ -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'
diff --git a/config/initializers/ostatus.rb b/config/initializers/ostatus.rb
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
@@ -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: .
diff --git a/docs/Running-Mastodon/Production-guide.md b/docs/Running-Mastodon/Production-guide.md
@@ -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
diff --git a/package.json b/package.json
@@ -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"
}
}
diff --git a/streaming/index.js b/streaming/index.js
@@ -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}`)
+})
diff --git 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"