forked from fedi/mastodon
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.
This commit is contained in:
parent
8c0bc1309f
commit
ccb8ac8573
|
@ -43,5 +43,5 @@ SMTP_FROM_ADDRESS=notifications@example.com
|
||||||
# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
|
# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
|
||||||
# S3_CLOUDFRONT_HOST=
|
# S3_CLOUDFRONT_HOST=
|
||||||
|
|
||||||
# Optional Firebase Cloud Messaging API key
|
# Streaming API integration
|
||||||
FCM_API_KEY=
|
# STREAMING_API_BASE_URL=
|
||||||
|
|
|
@ -13,4 +13,3 @@
|
||||||
//= require jquery
|
//= require jquery
|
||||||
//= require jquery_ujs
|
//= require jquery_ujs
|
||||||
//= require components
|
//= require components
|
||||||
//= require cable
|
|
||||||
|
|
|
@ -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);
|
|
|
@ -43,6 +43,7 @@ import hu from 'react-intl/locale-data/hu';
|
||||||
import uk from 'react-intl/locale-data/uk';
|
import uk from 'react-intl/locale-data/uk';
|
||||||
import getMessagesForLocale from '../locales';
|
import getMessagesForLocale from '../locales';
|
||||||
import { hydrateStore } from '../actions/store';
|
import { hydrateStore } from '../actions/store';
|
||||||
|
import createStream from '../stream';
|
||||||
|
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
|
|
||||||
|
@ -60,11 +61,11 @@ const Mastodon = React.createClass({
|
||||||
locale: React.PropTypes.string.isRequired
|
locale: React.PropTypes.string.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillMount() {
|
componentDidMount() {
|
||||||
const { locale } = this.props;
|
const { locale } = this.props;
|
||||||
|
const accessToken = store.getState().getIn(['meta', 'access_token']);
|
||||||
|
|
||||||
if (typeof App !== 'undefined') {
|
this.subscription = createStream(accessToken, 'user', {
|
||||||
this.subscription = App.cable.subscriptions.create('TimelineChannel', {
|
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
|
@ -81,7 +82,6 @@ const Mastodon = React.createClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Desktop notifications
|
// Desktop notifications
|
||||||
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
|
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
|
||||||
|
@ -91,7 +91,8 @@ const Mastodon = React.createClass({
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
if (typeof this.subscription !== 'undefined') {
|
if (typeof this.subscription !== 'undefined') {
|
||||||
this.subscription.unsubscribe();
|
this.subscription.close();
|
||||||
|
this.subscription = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -8,22 +8,26 @@ import {
|
||||||
deleteFromTimelines
|
deleteFromTimelines
|
||||||
} from '../../actions/timelines';
|
} from '../../actions/timelines';
|
||||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
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({
|
const HashtagTimeline = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
params: React.PropTypes.object.isRequired,
|
params: React.PropTypes.object.isRequired,
|
||||||
dispatch: React.PropTypes.func.isRequired
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
|
accessToken: React.PropTypes.string.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
_subscribe (dispatch, id) {
|
_subscribe (dispatch, id) {
|
||||||
if (typeof App !== 'undefined') {
|
const { accessToken } = this.props;
|
||||||
this.subscription = App.cable.subscriptions.create({
|
|
||||||
channel: 'HashtagChannel',
|
this.subscription = createStream(accessToken, `hashtag&tag=${id}`, {
|
||||||
tag: id
|
|
||||||
}, {
|
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
|
@ -37,16 +41,16 @@ const HashtagTimeline = React.createClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_unsubscribe () {
|
_unsubscribe () {
|
||||||
if (typeof this.subscription !== 'undefined') {
|
if (typeof this.subscription !== 'undefined') {
|
||||||
this.subscription.unsubscribe();
|
this.subscription.close();
|
||||||
|
this.subscription = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillMount () {
|
componentDidMount () {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
const { id } = this.props.params;
|
const { id } = this.props.params;
|
||||||
|
|
||||||
|
@ -79,4 +83,4 @@ const HashtagTimeline = React.createClass({
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect()(HashtagTimeline);
|
export default connect(mapStateToProps)(HashtagTimeline);
|
||||||
|
|
|
@ -9,27 +9,32 @@ import {
|
||||||
} from '../../actions/timelines';
|
} from '../../actions/timelines';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||||
|
import createStream from '../../stream';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.public', defaultMessage: 'Public' }
|
title: { id: 'column.public', defaultMessage: 'Public' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
accessToken: state.getIn(['meta', 'access_token'])
|
||||||
|
});
|
||||||
|
|
||||||
const PublicTimeline = React.createClass({
|
const PublicTimeline = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
dispatch: React.PropTypes.func.isRequired,
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
intl: React.PropTypes.object.isRequired
|
intl: React.PropTypes.object.isRequired,
|
||||||
|
accessToken: React.PropTypes.string.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
componentWillMount () {
|
componentDidMount () {
|
||||||
const { dispatch } = this.props;
|
const { dispatch, accessToken } = this.props;
|
||||||
|
|
||||||
dispatch(refreshTimeline('public'));
|
dispatch(refreshTimeline('public'));
|
||||||
|
|
||||||
if (typeof App !== 'undefined') {
|
this.subscription = createStream(accessToken, 'public', {
|
||||||
this.subscription = App.cable.subscriptions.create('PublicChannel', {
|
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
switch(data.event) {
|
switch(data.event) {
|
||||||
|
@ -43,12 +48,12 @@ const PublicTimeline = React.createClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
if (typeof this.subscription !== 'undefined') {
|
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
app/assets/javascripts/components/stream.jsx
Normal file
21
app/assets/javascripts/components/stream.jsx
Normal file
|
@ -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,5 +1,6 @@
|
||||||
- content_for :header_tags do
|
- content_for :header_tags do
|
||||||
:javascript
|
: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))}
|
window.INITIAL_STATE = #{json_escape(render(file: 'home/initial_state', formats: :json))}
|
||||||
|
|
||||||
= javascript_include_tag 'application'
|
= javascript_include_tag 'application'
|
||||||
|
|
|
@ -10,8 +10,10 @@ Rails.application.configure do
|
||||||
config.x.use_s3 = ENV['S3_ENABLED'] == 'true'
|
config.x.use_s3 = ENV['S3_ENABLED'] == 'true'
|
||||||
|
|
||||||
config.action_mailer.default_url_options = { host: host, protocol: https ? 'https://' : 'http://', trailing_slash: false }
|
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?
|
if Rails.env.production?
|
||||||
config.action_cable.allowed_request_origins = ["http#{https ? 's' : ''}://#{host}"]
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -19,6 +19,16 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/assets:/mastodon/public/assets
|
- ./public/assets:/mastodon/public/assets
|
||||||
- ./public/system:/mastodon/public/system
|
- ./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:
|
sidekiq:
|
||||||
restart: always
|
restart: always
|
||||||
build: .
|
build: .
|
||||||
|
|
|
@ -49,6 +49,22 @@ server {
|
||||||
tcp_nodelay on;
|
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;
|
error_page 500 501 502 503 504 /500.html;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@ -162,6 +178,27 @@ Restart=always
|
||||||
WantedBy=multi-user.target
|
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.
|
This allows you to `sudo systemctl enable mastodon-*.service` and `sudo systemctl start mastodon-*.service` to get things going.
|
||||||
|
|
||||||
## Cronjobs
|
## Cronjobs
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
"babelify": "^7.3.0",
|
"babelify": "^7.3.0",
|
||||||
"browserify": "^13.1.0",
|
"browserify": "^13.1.0",
|
||||||
"browserify-incremental": "^3.1.1",
|
"browserify-incremental": "^3.1.1",
|
||||||
|
"bufferutil": "^2.0.0",
|
||||||
"chai": "^3.5.0",
|
"chai": "^3.5.0",
|
||||||
"chai-enzyme": "^0.5.2",
|
"chai-enzyme": "^0.5.2",
|
||||||
"css-loader": "^0.26.1",
|
"css-loader": "^0.26.1",
|
||||||
|
@ -64,6 +65,9 @@
|
||||||
"sass-loader": "^4.0.2",
|
"sass-loader": "^4.0.2",
|
||||||
"sinon": "^1.17.6",
|
"sinon": "^1.17.6",
|
||||||
"style-loader": "^0.13.1",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import http from 'http'
|
||||||
import redis from 'redis'
|
import redis from 'redis'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import log from 'npmlog'
|
import log from 'npmlog'
|
||||||
|
import url from 'url'
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
|
||||||
const env = process.env.NODE_ENV || 'development'
|
const env = process.env.NODE_ENV || 'development'
|
||||||
|
|
||||||
|
@ -29,6 +32,8 @@ const pgConfigs = {
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const pgPool = new pg.Pool(pgConfigs[env])
|
const pgPool = new pg.Pool(pgConfigs[env])
|
||||||
|
const server = http.createServer(app)
|
||||||
|
const wss = new WebSocket.Server({ server })
|
||||||
|
|
||||||
const allowCrossDomain = (req, res, next) => {
|
const allowCrossDomain = (req, res, next) => {
|
||||||
res.header('Access-Control-Allow-Origin', '*')
|
res.header('Access-Control-Allow-Origin', '*')
|
||||||
|
@ -38,22 +43,7 @@ const allowCrossDomain = (req, res, next) => {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticationMiddleware = (req, res, next) => {
|
const accountFromToken = (token, req, 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 /, '')
|
|
||||||
|
|
||||||
pgPool.connect((err, client, done) => {
|
pgPool.connect((err, client, done) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return next(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) => {
|
const errorMiddleware = (err, req, res, next) => {
|
||||||
log.error(err)
|
log.error(err)
|
||||||
res.writeHead(err.statusCode || 500, { 'Content-Type': 'application/json' })
|
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 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}`)
|
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) => {
|
redisClient.on('message', (channel, message) => {
|
||||||
const { event, payload } = JSON.parse(message)
|
const { event, payload } = JSON.parse(message)
|
||||||
|
|
||||||
|
@ -127,36 +127,107 @@ const streamFrom = (id, req, res, needsFiltering = false) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.write(`event: ${event}\n`)
|
log.silly(`Transmitting for ${req.accountId}: ${event} ${payload}`)
|
||||||
res.write(`data: ${payload}\n\n`)
|
output(event, payload)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
res.write(`event: ${event}\n`)
|
log.silly(`Transmitting for ${req.accountId}: ${event} ${payload}`)
|
||||||
res.write(`data: ${payload}\n\n`)
|
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)
|
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(allowCrossDomain)
|
||||||
app.use(authenticationMiddleware)
|
app.use(authenticationMiddleware)
|
||||||
app.use(errorMiddleware)
|
app.use(errorMiddleware)
|
||||||
|
|
||||||
app.get('/api/v1/streaming/user', (req, res) => streamFrom(`timeline:${req.accountId}`, req, res))
|
app.get('/api/v1/streaming/user', (req, res) => {
|
||||||
app.get('/api/v1/streaming/public', (req, res) => streamFrom('timeline:public', req, res, true))
|
const redisClient = getRedisClient()
|
||||||
app.get('/api/v1/streaming/hashtag', (req, res) => streamFrom(`timeline:hashtag:${req.params.tag}`, req, res, true))
|
streamFrom(redisClient, `timeline:${req.accountId}`, req, streamToHttp(req, res, redisClient))
|
||||||
|
})
|
||||||
|
|
||||||
log.level = 'verbose'
|
app.get('/api/v1/streaming/public', (req, res) => {
|
||||||
log.info(`Starting HTTP server on port ${process.env.PORT || 4000}`)
|
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}`)
|
||||||
|
})
|
||||||
|
|
61
yarn.lock
61
yarn.lock
|
@ -1237,6 +1237,12 @@ babylon@^6.15.0:
|
||||||
version "6.15.0"
|
version "6.15.0"
|
||||||
resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.15.0.tgz#ba65cfa1a80e1759b0e89fb562e27dccae70348e"
|
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:
|
balanced-match@^0.4.1, balanced-match@^0.4.2:
|
||||||
version "0.4.2"
|
version "0.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838"
|
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"
|
version "1.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.7.0.tgz#6c1610db163abfb34edfe42fa423343a1e01185d"
|
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:
|
bl@~1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/bl/-/bl-1.1.2.tgz#fdca871a99713aa00d19e3bbba41c44787a65398"
|
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"
|
ieee754 "^1.1.4"
|
||||||
isarray "^1.0.0"
|
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:
|
builtin-modules@^1.0.0:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
|
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"
|
version "0.7.2"
|
||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765"
|
||||||
|
|
||||||
nan@^2.3.0, nan@^2.3.2:
|
nan@^2.3.0, nan@^2.3.2, nan@~2.5.0:
|
||||||
version "2.4.0"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/nan/-/nan-2.4.0.tgz#fb3c59d45fe4effe215f0b890f8adf6eb32d2232"
|
resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.1.tgz#d5b01691253326a97a2bbee9e61c55d8d60351e2"
|
||||||
|
|
||||||
negotiator@0.6.1:
|
negotiator@0.6.1:
|
||||||
version "0.6.1"
|
version "0.6.1"
|
||||||
|
@ -3808,16 +3825,7 @@ normalize-url@^1.4.0:
|
||||||
gauge "~2.6.0"
|
gauge "~2.6.0"
|
||||||
set-blocking "~2.0.0"
|
set-blocking "~2.0.0"
|
||||||
|
|
||||||
npmlog@4.x, npmlog@^4.0.0:
|
npmlog@4.x, npmlog@^4.0.0, npmlog@^4.0.2:
|
||||||
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:
|
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.2.tgz#d03950e0e78ce1527ba26d2a7592e9348ac3e75f"
|
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.2.tgz#d03950e0e78ce1527ba26d2a7592e9348ac3e75f"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -4401,6 +4409,10 @@ postgres-interval@~1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xtend "^4.0.0"
|
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:
|
prelude-ls@~1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
|
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"
|
version "0.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
|
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:
|
umd@^3.0.0:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/umd/-/umd-3.0.1.tgz#8ae556e11011f63c2596708a8837259f01b3d60e"
|
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"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190"
|
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:
|
util-deprecate@~1.0.1:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
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"
|
watchpack "^0.2.1"
|
||||||
webpack-core "~0.6.9"
|
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:
|
whatwg-fetch@>=0.10.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-1.0.0.tgz#01c2ac4df40e236aaa18480e3be74bd5c8eb798e"
|
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"
|
imurmurhash "^0.1.4"
|
||||||
slide "^1.1.5"
|
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:
|
xdg-basedir@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"
|
resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"
|
||||||
|
|
Loading…
Reference in a new issue