├── .eslintrc ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── deduplicator.js ├── github.js ├── gulpfile.js ├── package.json ├── public ├── chart.js ├── index.html ├── list.js ├── main.js └── screen.css ├── screenshot.gif └── server.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "curly": 2, 9 | "quotes": [2, "single"] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | *.swp 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "0.11" 5 | - "0.10" 6 | before_install: 7 | - npm install -g gulp 8 | script: 9 | - gulp 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:0.12-onbuild 2 | EXPOSE 3000 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Lukas Martinelli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Realtime Relay [![Build Status](https://travis-ci.org/lukasmartinelli/ghrr.svg)](https://travis-ci.org/lukasmartinelli/ghrr) [![Code Climate](https://codeclimate.com/github/lukasmartinelli/ghrr/badges/gpa.svg)](https://codeclimate.com/github/lukasmartinelli/ghrr) 2 | 3 | > :warning: This repository is no longer maintained by Lukas Martinelli. 4 | 5 | Receive all GitHub events in realtime with [socket.io](http://socket.io/) from the [GitHub Realtime Relay](http://ghrr.lukasmartinelli.ch) which polls all public events and then relays them directly via websockets. 6 | This is probably the simplest way to create a realtime application on top of GitHub. 7 | 8 | Below you see a statistics page of GitHub events built on top of [GHRR](http://ghrr.lukasmartinelli.ch). 9 | 10 | [![Screenshot of GitHub Realtime Relay](screenshot.gif)](http://ghrr.lukasmartinelli.ch) 11 | 12 | For a short tutorial head over to 13 | [my blog post about GHRR](http://lukasmartinelli.ch/web/2015/07/29/github-realtime-relay.html) or continue reading. For a more sophisticated usage example checkout my other project [delptr](http://github.com/lukasmartinelli/delptr), which lints all C++ commits in realtime. 14 | 15 | ## Connect from Server (Node) 16 | 17 | Install the [socket.io-client](https://www.npmjs.org/package/socket.io-client) from npm. 18 | 19 | ```bash 20 | npm install socket.io-client 21 | ``` 22 | 23 | To receive all events you can hook onto the `/events` namespace 24 | and subscribe to a [specific GitHub Event](https://developer.github.com/v3/activity/events/types/). Please use lower case for subscribing to the event types. 25 | 26 | ```javascript 27 | var url = 'http://ghrr.lukasmartinelli.ch:80/events'; 28 | var socket = require('socket.io-client')(url); 29 | 30 | socket.on('pushevent', function(event){ 31 | console.log('Push: ' + event.repository.full_name); 32 | }); 33 | 34 | ``` 35 | 36 | There is also a `/statistics` namespace used by the GHRR web interface that 37 | sends usage statistics for the Event Types. 38 | 39 | ```javascript 40 | var url = 'http://ghrr.lukasmartinelli.ch:80'; 41 | var io = require('socket.io-client')(url); 42 | io('/statistics').on('types', function(typeCounts) { 43 | console.log('PushEvents: ' + typeCounts.pushevent); 44 | } 45 | ``` 46 | 47 | ## Connect from Web Application 48 | 49 | You need to add the socket.io-client to your web application. 50 | 51 | ```html 52 | 53 | ``` 54 | 55 | You can now connect directly to the public websocket. We support 56 | [CORS](http://www.html5rocks.com/en/tutorials/cors/) 57 | for all domains so you should not encounter any problems. 58 | 59 | ```javascript 60 | var url = 'http://ghrr.lukasmartinelli.ch:80/events'; 61 | var socket = io(url); 62 | 63 | socket.on('pushevent', function (event) { 64 | console.log('Push: ' + event.repository.full_name); 65 | }); 66 | ``` 67 | 68 | ## Host it yourself 69 | 70 | In order to poll all events you need an OAUTH access token. 71 | Run the github realtime relay with a poll rate of `1000` and on port `3000`. 72 | 73 | ```bash 74 | docker pull lukasmartinelli/ghrr 75 | docker run -e GITHUB_TOKEN="acbas3dfas.." -p 3000:3000 lukasmartinelli/ghrr 76 | ``` 77 | -------------------------------------------------------------------------------- /deduplicator.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var CBuffer = require('CBuffer'); 3 | 4 | var buffer = new CBuffer(5); 5 | buffer.fill({}); 6 | 7 | module.exports = { 8 | isUnique: function(event) { 9 | var hasDuplicates = buffer.some(function(ids) { 10 | var exists = event.id in ids; 11 | ids[event.id] = true; 12 | return exists; 13 | }); 14 | return !hasDuplicates; 15 | }, 16 | discardOldest: function() { 17 | buffer.push({}); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /github.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var request = require('request'); 3 | 4 | module.exports = function(accessToken) { 5 | var options = { 6 | url: 'https://api.github.com/events', 7 | headers: { 8 | 'User-Agent': 'GHHR', 9 | 'Authorization': 'token ' + accessToken, 10 | 'If-None-Match': '""' 11 | } 12 | }; 13 | var log = { 14 | ratelimit: { 15 | limit: 5000, 16 | remaining: 0, 17 | reset: 0 18 | }, 19 | events: 0, 20 | requests: 0 21 | }; 22 | var parseInfo = function(headers, events) { 23 | options.headers['If-None-Match'] = headers.etag; 24 | log.ratelimit.limit = headers['x-ratelimit-limit']; 25 | log.ratelimit.remaining = headers['x-ratelimit-remaining']; 26 | log.ratelimit.reset = headers['x-ratelimit-reset'] * 1000; 27 | 28 | log.events += events.length; 29 | }; 30 | return { 31 | getEvents: function(callback) { 32 | log.requests += 1; 33 | request(options, function(error, response, body) { 34 | if(error) { 35 | console.error(error); 36 | } 37 | if(response.statusCode === 304) { 38 | callback([]); 39 | } 40 | if(response.statusCode === 200) { 41 | var events = JSON.parse(body); 42 | parseInfo(response.headers, events); 43 | callback(events); 44 | } 45 | }); 46 | }, 47 | getInfo: function() { 48 | return log; 49 | } 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var gulp = require('gulp'); 3 | var eslint = require('gulp-eslint'); 4 | 5 | gulp.task('lint', function () { 6 | return gulp.src(['*.js', 'tests/*.js']) 7 | .pipe(eslint()) 8 | .pipe(eslint.format()) 9 | .pipe(eslint.failOnError()); 10 | }); 11 | 12 | gulp.task('watch', function () { 13 | gulp.watch('*.js', ['lint']); 14 | }); 15 | 16 | gulp.task('default', ['lint']); 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghrr", 3 | "version": "1.0.0", 4 | "description": "Github Realtime Relay", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/lukasmartinelli/ghrr.git" 13 | }, 14 | "keywords": [ 15 | "github", 16 | "realtime", 17 | "relay", 18 | "socket" 19 | ], 20 | "author": "Lukas Martinelli", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/lukasmartinelli/ghrr/issues" 24 | }, 25 | "homepage": "https://github.com/lukasmartinelli/ghrr", 26 | "dependencies": { 27 | "CBuffer": "^0.1.5", 28 | "baconjs": "^0.7.35", 29 | "express": "^4.10.2", 30 | "request": "^2.48.0", 31 | "socket.io": "^1.2.0" 32 | }, 33 | "devDependencies": { 34 | "gulp": "^3.8.10", 35 | "gulp-eslint": "^0.2.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/chart.js: -------------------------------------------------------------------------------- 1 | var EventTypeChart = function(el, eventTypes){ 2 | var chartData = _.chain(eventTypes) 3 | .filter(function(type) { return type.plottable; }) 4 | .map(function(type) { 5 | return { 6 | label: type.label, 7 | values: [{ time: new Date(), y: type.count }] 8 | }; 9 | }).value(); 10 | 11 | return $(el).epoch({ 12 | type: 'time.bar', 13 | data: chartData, 14 | axes: ['left', 'bottom'], 15 | ticks: { time: 10, left: 5}, 16 | tickFormats: { bottom: function(d) { return d.toLocaleTimeString(); } }, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Github Realtime Relay 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Fork me on GitHub 15 | 16 |
17 |
18 |

Github Realtime Relay

19 | 20 |
21 |
22 |
23 |
24 |

Connect from Server (Node)

25 |
26 |                     
27 | var url = 'ghrr.lukasmartinelli.ch:80/events';
28 | var socket = require('socket.io-client')(url);
29 | 
30 | socket.on('pushevent', function(event){
31 |    console.log('Push: ' + event.repository.full_name);
32 | });
33 |                     
34 |                 
35 | 36 | Documentation for Node 37 | 38 |
39 |
40 |

Connect from Web Application

41 |
42 |                     
43 | var url = 'http://ghrr.lukasmartinelli.ch:80/events';
44 | var socket = io(url);
45 | 
46 | socket.on('pushevent', function (event) {
47 |    console.log('Push: ' + event.repository.full_name);
48 | });
49 |                     
50 |                 
51 | 52 | Documentation for Web Apps 53 | 54 |
55 |
56 |
57 |

Live Statistics

58 |
59 |
60 |
61 |

62 |
63 |
64 |
65 |
66 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /public/list.js: -------------------------------------------------------------------------------- 1 | var EventTypeList = function() { 2 | var types = [ 3 | { name: 'pushevent', label: 'push', color: '#1f77b4', plottable: true }, 4 | { name: 'issuecommentevent', label: 'issue comment', 5 | color: '#ff7f0e', plottable: true}, 6 | { name: 'createevent', label: 'create', 7 | color: '#2ca02c', plottable: true}, 8 | { name: 'watchevent', label: 'watch', 9 | color: '#d62728', plottable: true }, 10 | { name: 'pullrequestevent', label: 'pull request', 11 | color: '#9467bd', plottable: true}, 12 | { name: 'issuesevent', label: 'issues', 13 | color: '#8c564b', plottable: true}, 14 | { name: 'pullrequestreviewcommentevent', label: 'review comment', 15 | color: '#e377c2', plottable: true}, 16 | { name: 'deleteevent', label: 'delete', 17 | color: '#7f7f7f', plottable: true }, 18 | { name: 'forkevent', label: 'fork', color: '#bcbd22', plottable: true }, 19 | { name: 'commitcommentevent', label: 'commit comment'}, 20 | { name: 'followevent', label: 'follow'}, 21 | { name: 'gollumevent', label: 'gollum'}, 22 | { name: 'memberevent', label: 'member'}, 23 | { name: 'downloadevent', label: 'download'}, 24 | { name: 'pagebuildevent', label: 'page build'}, 25 | { name: 'publicevent', label: 'public'}, 26 | { name: 'forkapplyevent', label: 'fork apply'}, 27 | { name: 'gistevent', label: 'gist'}, 28 | { name: 'releaseevent', label: 'release'}, 29 | { name: 'deploymentevent', label: 'deployment'}, 30 | { name: 'deploymentstatusevent', label: 'deployment status'}, 31 | { name: 'statusevent', label: 'status'}, 32 | { name: 'teamaddevent', label: 'team add'}, 33 | ]; 34 | 35 | var EventType = function(name, label, plottable, color) { 36 | this.name = name; 37 | this.label = label; 38 | this.count = 0; 39 | this.allCount = ko.observable(0); 40 | this.startDate = new Date(); 41 | this.updateDate = ko.observable(new Date()); 42 | this.plottable = plottable; 43 | this.color = color; 44 | this.resets = 0; 45 | 46 | this.countPerSecond = ko.computed(function() { 47 | var diff = (this.updateDate() - this.startDate) / 1000; 48 | var avg = this.allCount() / diff; 49 | return isNaN(avg) || !isFinite(avg) ? 0 : avg.toFixed(2); 50 | }.bind(this)); 51 | 52 | this.increment = function(count) { 53 | this.updateDate(new Date()); 54 | this.count += count; 55 | this.allCount(this.allCount() + count); 56 | }; 57 | 58 | this.reset = function() { 59 | this.resets += 1; 60 | if(this.resets % 10 == 0) { 61 | this.startDate = new Date(); 62 | this.allCount(0); 63 | } 64 | this.count = 0; 65 | } 66 | }; 67 | 68 | var eventTypes = _.map(types, function(type) { 69 | return new EventType(type.name, type.label, 70 | type.plottable || false, 71 | type.color || '#759BB3'); 72 | }); 73 | 74 | return { 75 | types: ko.observableArray(eventTypes) 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /public/main.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | var list = EventTypeList(); 3 | var chart = EventTypeChart(document.getElementById('event-chart'), 4 | list.types()); 5 | 6 | hljs.initHighlightingOnLoad(); 7 | ko.applyBindings(list); 8 | 9 | io('/statistics').on('types', function(typeCounts) { 10 | list.types().forEach(function (type) { 11 | type.increment(typeCounts[type.name]); 12 | }); 13 | }); 14 | 15 | window.setInterval(function() { 16 | var current = new Date(); 17 | var dataPoint = _.chain(list.types()) 18 | .filter('plottable') 19 | .map(function(type) { 20 | var count = type.count; 21 | type.reset(); 22 | return { time: current, y: count }; 23 | }).value(); 24 | chart.push(dataPoint); 25 | }, 1000); 26 | })(); 27 | -------------------------------------------------------------------------------- /public/screen.css: -------------------------------------------------------------------------------- 1 | /*===TITLES AND FONTS===*/ 2 | body { 3 | font-family: 'PT Sans', sans-serif; 4 | } 5 | 6 | h1 { 7 | font-size: 5em; 8 | font-weight: 900; 9 | line-height: 1.2em; 10 | font-family: 'Alegreya Sans SC', sans-serif; 11 | color: #324d5b; 12 | margin: 0; 13 | } 14 | 15 | h2 { 16 | font-family: 'Alegreya Sans SC', sans-serif; 17 | font-weight: 300; 18 | font-size: 1.8em; 19 | margin: 0; 20 | } 21 | 22 | h3 { 23 | font-family: 'Alegreya Sans SC', sans-serif; 24 | font-weight: 300; 25 | font-size: 1.8em; 26 | margin: 0; 27 | } 28 | 29 | h4 { 30 | font-family: 'Alegreya Sans SC', sans-serif; 31 | text-align: center; 32 | margin: 0; 33 | font-size: 1.8em; 34 | font-weight: 300; 35 | } 36 | 37 | pre { 38 | margin: 0; 39 | } 40 | 41 | section { 42 | padding: 0.5em; 43 | } 44 | 45 | /*===BUTTONS===*/ 46 | .button { 47 | font-family: 'Alegreya Sans SC', sans-serif; 48 | font-size: 1.2em; 49 | padding: 0.5em; 50 | text-decoration: none; 51 | background-color: #A6BFCE; 52 | color: rgba(0, 0, 0, 0.87); 53 | transition: all 450ms cubic-bezier(0.23, 1, 0.32, 1) 0ms; 54 | box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.24); 55 | border-radius: 2px; 56 | } 57 | 58 | .button:hover { 59 | background-color: #4D7995; 60 | } 61 | 62 | /*===GITHUB BADGE===*/ 63 | .github { 64 | position: absolute; 65 | top: 0; 66 | right: 0; 67 | border: 0; 68 | } 69 | 70 | /*===EVENT TYPES===*/ 71 | #event-types > article { 72 | float: left; 73 | margin: 0.4em; 74 | width: 5em; 75 | background-color: #A6BFCE; 76 | box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.24); 77 | border-radius: 2px; 78 | color: #fff; 79 | height: 5em; 80 | padding: 0.2em; 81 | } 82 | 83 | #event-types:before, #event-types:after { 84 | content: " "; 85 | display: table; 86 | } 87 | 88 | #event-types:after { 89 | clear: both; 90 | } 91 | 92 | .type { 93 | font-family: monospace; 94 | width: 100%; 95 | text-align: center; 96 | font-size: 1.2em; 97 | } 98 | 99 | /*===CHART===*/ 100 | .chart { 101 | margin-top: 1em; 102 | } 103 | 104 | /*===TUTORIAL===*/ 105 | .tutorial code { 106 | min-height: 8em; 107 | } 108 | 109 | .tutorial-server, .tutorial-client { 110 | margin-top: 1em; 111 | } 112 | 113 | .tutorial:before, .tutorial:after { 114 | content: " "; 115 | display: table; 116 | } 117 | 118 | .tutorial:after { 119 | clear: both; 120 | } 121 | 122 | @media (min-width: 55em) { 123 | .tutorial-server { 124 | float: left; 125 | width: 49%; 126 | } 127 | 128 | .tutorial-client { 129 | margin-left: 2%; 130 | float: left; 131 | width: 49%; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukasmartinelli/ghrr/5628d67179664b516eec5fd0b853896cb9df817f/screenshot.gif -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /*eslint new-cap:0 */ 2 | 'use strict'; 3 | var path = require('path'); 4 | var express = require('express'); 5 | var app = express(); 6 | var http = require('http').Server(app); 7 | var io = require('socket.io')(http); 8 | var Bacon = require('baconjs').Bacon; 9 | 10 | var accessToken = process.env.GITHUB_TOKEN; 11 | var pollInterval = process.env.POLL_INTERVAL || 1000; 12 | var port = process.env.VCAP_APP_PORT || 3000; 13 | var types = ['pushevent', 'issuecommentevent', 'createevent', 'watchevent', 14 | 'pullrequestevent', 'issuesevent', 'pullrequestreviewcommentevent', 15 | 'deleteevent', 'forkevent', 'commitcommentevent', 'followevent', 16 | 'gollumevent', 'memberevent', 'downloadevent', 'pagebuildevent', 17 | 'publicevent', 'forkapplyevent', 'gistevent', 'releaseevent', 18 | 'deploymentevent', 'deploymentstatusevent', 'statusevent', 19 | 'teamaddevent']; 20 | 21 | var deduplicator = require('./deduplicator'); 22 | var client = require('./github')(accessToken); 23 | 24 | var ioStats = io.of('/statistics'); 25 | var ioEvents = io.of('/events'); 26 | 27 | io.set('origins', '*:*'); 28 | app.use(express.static(path.join(__dirname, 'public'))); 29 | http.listen(port); 30 | 31 | var emitStatistics = function(events) { 32 | var typeCounts = {}; 33 | 34 | types.forEach(function(t) { 35 | var count = events.filter(function(e) { 36 | return e.type.toLowerCase() === t; 37 | }).length; 38 | typeCounts[t] = count; 39 | }); 40 | ioStats.emit('types', typeCounts); 41 | }; 42 | 43 | var emitEvent = function(event) { 44 | var timestamp = new Date(event.created_at).toLocaleTimeString(); 45 | console.log([event.id, event.type, timestamp].join('\t')); 46 | ioEvents.emit(event.type.toLowerCase(), event); 47 | }; 48 | 49 | var interval = Bacon.interval(pollInterval); 50 | var eventStream = interval.flatMap(function() { 51 | return Bacon.fromCallback(client.getEvents); 52 | }) 53 | .flatMap(Bacon.fromArray) 54 | .filter(deduplicator.isUnique); 55 | 56 | eventStream.bufferWithTime(pollInterval).onValue(emitStatistics); 57 | eventStream.onValue(emitEvent); 58 | interval.delay(5 * pollInterval).onValue(deduplicator.discardOldest); 59 | --------------------------------------------------------------------------------