From ccb8ac857330e1ad3aee37b340e5c6e242ac1dd6 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 4 Feb 2017 00:34:31 +0100 Subject: [PATCH] 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. --- .env.production.sample | 4 +- app/assets/javascripts/application.js | 1 - app/assets/javascripts/cable.js | 12 -- .../components/containers/mastodon.jsx | 39 +++-- .../features/hashtag_timeline/index.jsx | 44 ++--- .../features/public_timeline/index.jsx | 41 +++-- app/assets/javascripts/components/stream.jsx | 21 +++ app/views/home/index.html.haml | 1 + config/initializers/ostatus.rb | 2 + docker-compose.yml | 10 ++ docs/Running-Mastodon/Production-guide.md | 37 ++++ package.json | 6 +- streaming/index.js | 163 +++++++++++++----- yarn.lock | 61 +++++-- 14 files changed, 310 insertions(+), 132 deletions(-) delete mode 100644 app/assets/javascripts/cable.js create mode 100644 app/assets/javascripts/components/stream.jsx diff --git a/.env.production.sample b/.env.production.sample index 1a96775de..ef0af9d5c 100644 --- 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 index c442ded61..e2fffd932 100644 --- 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 deleted file mode 100644 index 03258761c..000000000 --- a/app/assets/javascripts/cable.js +++ /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); diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index 5fd43fb2b..46a01b200 100644 --- 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; + componentDidMount() { + const { locale } = this.props; + const accessToken = store.getState().getIn(['meta', 'access_token']); - if (typeof App !== 'undefined') { - this.subscription = App.cable.subscriptions.create('TimelineChannel', { + 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; - } + 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 index 7548e6d56..4a0e7684d 100644 --- 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 - }, { + const { accessToken } = this.props; - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('tag', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } + 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 index 42970061c..36d68dbbb 100644 --- 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 new file mode 100644 index 000000000..0787399f6 --- /dev/null +++ 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 index 0147f4064..9e3b94463 100644 --- 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 index faa9940b0..fb0b8b7fe 100644 --- 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 index e1f1f1c4c..e6002eaa5 100644 --- 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 index 76964d995..ff4427dd2 100644 --- 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 index 9685f07a4..def42f596 100644 --- 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 index e5a2778f8..16dda5c1e 100644 --- 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) } }) - const heartbeat = setInterval(() => res.write(':thump\n'), 15000) - - req.on('close', () => { - log.verbose(`Ending stream from ${id} for ${req.accountId}`) - clearInterval(heartbeat) - redisClient.quit() - }) - 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 for ${req.accountId}`) + clearInterval(heartbeat) + redisClient.quit() + }) + + 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)) +}) -log.level = 'verbose' -log.info(`Starting HTTP server on port ${process.env.PORT || 4000}`) +app.get('/api/v1/streaming/public', (req, res) => { + const redisClient = getRedisClient() + streamFrom(redisClient, 'timeline:public', req, streamToHttp(req, res, redisClient), true) +}) -app.listen(process.env.PORT || 4000) +app.get('/api/v1/streaming/hashtag', (req, res) => { + const redisClient = getRedisClient() + streamFrom(redisClient, `timeline:hashtag:${req.params.tag}`, req, streamToHttp(req, res, redisClient), true) +}) + +wss.on('connection', ws => { + const location = url.parse(ws.upgradeReq.url, true) + const token = location.query.access_token + const req = {} + + 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 index bd1747929..8038411fe 100644 --- 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"