Преглед на файлове

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.
master
Eugen Rochko преди 7 години
родител
ревизия
ccb8ac8573
променени са 14 файла, в които са добавени 307 реда и са изтрити 129 реда
  1. +2
    -2
      .env.production.sample
  2. +0
    -1
      app/assets/javascripts/application.js
  3. +0
    -12
      app/assets/javascripts/cable.js
  4. +22
    -21
      app/assets/javascripts/components/containers/mastodon.jsx
  5. +25
    -21
      app/assets/javascripts/components/features/hashtag_timeline/index.jsx
  6. +23
    -18
      app/assets/javascripts/components/features/public_timeline/index.jsx
  7. +21
    -0
      app/assets/javascripts/components/stream.jsx
  8. +1
    -0
      app/views/home/index.html.haml
  9. +2
    -0
      config/initializers/ostatus.rb
  10. +10
    -0
      docker-compose.yml
  11. +37
    -0
      docs/Running-Mastodon/Production-guide.md
  12. +5
    -1
      package.json
  13. +111
    -40
      streaming/index.js
  14. +48
    -13
      yarn.lock

+ 2
- 2
.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=

+ 0
- 1
app/assets/javascripts/application.js Целия файл

@@ -13,4 +13,3 @@
//= require jquery
//= require jquery_ujs
//= require components
//= require cable

+ 0
- 12
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);

+ 22
- 21
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;
}
},



+ 25
- 21
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);

+ 23
- 18
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));

+ 21
- 0
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;
};

+ 1
- 0
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'


+ 2
- 0
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

+ 10
- 0
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: .


+ 37
- 0
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


+ 5
- 1
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"
}
}

+ 111
- 40
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}`)
})

+ 48
- 13
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"


Зареждане…
Отказ
Запис