├── .gitignore ├── Dockerfile.ps ├── Dockerfile.rtp ├── GruntFile.js ├── README.md ├── app.js ├── custom_modules ├── data-channel.js └── event-storage.js ├── docker-compose.yml ├── package.json ├── post.js ├── static └── index.html └── views └── postupdate.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.js -------------------------------------------------------------------------------- /Dockerfile.ps: -------------------------------------------------------------------------------- 1 | # start with node 5 base image 2 | FROM node:5.0 3 | 4 | RUN groupadd -r nodejs && useradd -m -r -g nodejs nodejs 5 | 6 | USER root 7 | 8 | # Create an app directory (in the Docker container) 9 | RUN mkdir -p /usr/src/realtime 10 | WORKDIR /usr/src/realtime 11 | 12 | RUN npm install --production 13 | 14 | EXPOSE 8082 15 | 16 | USER nodejs 17 | 18 | CMD ["node", "post.js"] -------------------------------------------------------------------------------- /Dockerfile.rtp: -------------------------------------------------------------------------------- 1 | # start with node 5 base image 2 | FROM node:5.0 3 | 4 | RUN groupadd -r nodejs && useradd -m -r -g nodejs nodejs 5 | 6 | USER root 7 | 8 | # Create an app directory (in the Docker container) 9 | RUN mkdir -p /usr/src/realtime 10 | WORKDIR /usr/src/realtime 11 | 12 | RUN npm install --production 13 | 14 | EXPOSE 8081 15 | 16 | USER nodejs 17 | 18 | CMD ["node", "app.js"] -------------------------------------------------------------------------------- /GruntFile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | grunt.initConfig({ 3 | prompt: { 4 | config: { 5 | options: { 6 | questions: [ 7 | { 8 | config: "config.redisChannel", 9 | type: "input", 10 | message: "What is the Redis channel name for live updates?", 11 | default: "liveupdates", 12 | validate: function(value) { 13 | if (value.trim().length > 0) return true; 14 | 15 | return "You must enter a Redis channel name!"; 16 | } 17 | }, 18 | { 19 | config: "config.mongoDatabase", 20 | type: "input", 21 | message: "What is the Mongo database name for live events?", 22 | default: "events", 23 | validate: function(value) { 24 | if (value.trim().length > 0) return true; 25 | 26 | return "You must enter a Mongo database name!"; 27 | } 28 | }, 29 | { 30 | config: "config.mongoScoresCollection", 31 | type: "input", 32 | message: "What is the Mongo collection name for updates?", 33 | default: "updates", 34 | validate: function(value) { 35 | if (value.trim().length > 0) return true; 36 | 37 | return "You must enter a Mongo collection name!"; 38 | } 39 | } 40 | ] 41 | } 42 | }, 43 | }, 44 | }); 45 | 46 | grunt.loadNpmTasks('grunt-prompt'); 47 | 48 | grunt.registerTask("config", "Create config file", function() { 49 | var contents = 'var config = {\n' + 50 | ' redisChannel : "' + grunt.config("config.redisChannel") + '",\n' + 51 | ' mongoDatabase : "' + grunt.config("config.mongoDatabase") + '",\n' + 52 | ' mongoScoresCollection : "' + grunt.config("config.mongoScoresCollection") + '"\n' + 53 | '};\n\n' + 54 | 'module.exports = config;'; 55 | 56 | var path = "custom_modules/config.js"; 57 | 58 | grunt.file.write(path, contents); 59 | 60 | grunt.log.write("Config file generated").ok(); 61 | }); 62 | 63 | grunt.registerTask("default", [ 64 | "prompt:config", 65 | "config" 66 | ]); 67 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Realtime Demo 2 | 3 | This code is part of a 2 part blog series on building real-time web apps with Server-Sent Events. 4 | 5 | - [Part 1](http://bayn.es/real-time-web-applications-with-server-sent-events-pt-1/) 6 | - [Part 2](http://bayn.es/real-time-web-apps-with-server-sent-events-pt-2/) 7 | 8 | ## Installation 9 | 10 | npm install 11 | grunt 12 | 13 | ## Running with Docker 14 | 15 | docker-compose up -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require("express"), 2 | mustacheExpress = require("mustache-express"), 3 | dataChannel = require("./custom_modules/data-channel"), 4 | eventStorage = require("./custom_modules/event-storage"), 5 | app = express(); 6 | 7 | app.use(express.static("./static")); 8 | 9 | app.get("/api/updates", function(req, res){ 10 | initialiseSSE(req, res); 11 | 12 | if (typeof(req.headers["last-event-id"]) != "undefined") { 13 | replaySSEs(req, res); 14 | } 15 | }); 16 | 17 | function initialiseSSE(req, res) { 18 | dataChannel.subscribe(function(channel, message){ 19 | var json = JSON.parse(message); 20 | var messageEvent = new ServerEvent(json.timestamp); 21 | messageEvent.addData(json.update); 22 | outputSSE(req, res, messageEvent.payload()); 23 | }); 24 | 25 | res.set({ 26 | "Content-Type": "text/event-stream", 27 | "Cache-Control": "no-cache", 28 | "Connection": "keep-alive", 29 | "Access-Control-Allow-Origin": "*" 30 | }); 31 | 32 | res.write("retry: 10000\n\n"); 33 | } 34 | 35 | function replaySSEs(req, res) { 36 | var lastId = req.headers["last-event-id"]; 37 | 38 | eventStorage.findEventsSince(lastId).then(function(docs) { 39 | for (var index = 0; index < docs.length; index++) { 40 | var doc = docs[index]; 41 | var messageEvent = new ServerEvent(doc.timestamp); 42 | messageEvent.addData(doc.update); 43 | outputSSE(req, res, messageEvent.payload()); 44 | } 45 | }, errorHandling); 46 | }; 47 | 48 | function errorHandling(err) { 49 | throw err; 50 | } 51 | 52 | function outputSSE(req, res, data) { 53 | res.write(data); 54 | } 55 | 56 | function ServerEvent(name) { 57 | this.name = name || ""; 58 | this.data = ""; 59 | }; 60 | 61 | ServerEvent.prototype.addData = function(data) { 62 | var lines = data.split(/\n/); 63 | 64 | for (var i = 0; i < lines.length; i++) { 65 | var element = lines[i]; 66 | this.data += "data:" + element + "\n"; 67 | } 68 | } 69 | 70 | ServerEvent.prototype.payload = function() { 71 | var payload = ""; 72 | if (this.name != "") { 73 | payload += "id: " + this.name + "\n"; 74 | } 75 | 76 | payload += this.data; 77 | return payload + "\n"; 78 | } 79 | 80 | 81 | var server = app.listen(8081, function() { 82 | 83 | }); -------------------------------------------------------------------------------- /custom_modules/data-channel.js: -------------------------------------------------------------------------------- 1 | var redis = require("redis"), 2 | config = require("./config"); 3 | 4 | module.exports.subscribe = function(callback) { 5 | var subscriber = redis.createClient(6379,"redis"); 6 | 7 | subscriber.subscribe(config.redisChannel); 8 | 9 | subscriber.on("error", function(err){ 10 | console.log("Redis error: " + err); 11 | }); 12 | 13 | subscriber.on("message", callback); 14 | }; 15 | 16 | module.exports.publish = function(data) { 17 | var publisher = redis.createClient(6379,"redis"); 18 | 19 | publisher.publish(config.redisChannel, data); 20 | }; -------------------------------------------------------------------------------- /custom_modules/event-storage.js: -------------------------------------------------------------------------------- 1 | var Q = require("q"), 2 | config = require("./config"), 3 | mongo = require("mongojs"), 4 | db = mongo("mongodb://mongo/" + config.mongoDatabase), 5 | collection = db.collection(config.mongoScoresCollection); 6 | 7 | module.exports.save = function(data) { 8 | var deferred = Q.defer(); 9 | collection.save(data, function(err, doc){ 10 | if(err) { 11 | deferred.reject(err); 12 | } 13 | else { 14 | deferred.resolve(doc); 15 | } 16 | }); 17 | 18 | return deferred.promise; 19 | }; 20 | 21 | module.exports.findEventsSince = function(lastEventId) { 22 | var deferred = Q.defer(); 23 | 24 | collection.find({ 25 | timestamp: {$gt: Number(lastEventId)} 26 | }) 27 | .sort({timestamp: 1}, function(err, docs) { 28 | if (err) { 29 | deferred.reject(err); 30 | } 31 | else { 32 | deferred.resolve(docs); 33 | } 34 | }); 35 | 36 | return deferred.promise; 37 | }; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | realtime-ui: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.rtp 7 | ports: 8 | - 8081:8081 9 | volumes: 10 | - .:/usr/src/realtime 11 | realtime-post: 12 | build: 13 | context: . 14 | dockerfile: Dockerfile.ps 15 | ports: 16 | - 8082:8082 17 | volumes: 18 | - .:/usr/src/realtime 19 | redis: 20 | image: "redis:alpine" 21 | mongo: 22 | image: "mongo" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "realtime", 3 | "version": "1.0.0", 4 | "description": "An example of how to build a realtime app with Node.js", 5 | "main": "app.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/baynezy/RealtimeDemo.git" 9 | }, 10 | "author": "Simon Baynes", 11 | "license": "ISC", 12 | "bugs": { 13 | "url": "https://github.com/baynezy/RealtimeDemo/issues" 14 | }, 15 | "homepage": "https://github.com/baynezy/RealtimeDemo#readme", 16 | "dependencies": { 17 | "body-parser": "^1.13.3", 18 | "express": "^4.13.3", 19 | "mongojs": "^1.3.0", 20 | "mustache-express": "^1.2.1", 21 | "q": "^1.4.1", 22 | "redis": "^0.12.1" 23 | }, 24 | "devDependencies": { 25 | "grunt": "^0.4.5", 26 | "grunt-cli": "^0.1.13", 27 | "grunt-prompt": "^1.3.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /post.js: -------------------------------------------------------------------------------- 1 | var express = require("express"), 2 | mustacheExpress = require("mustache-express"), 3 | dataChannel = require("./custom_modules/data-channel"), 4 | eventStorage = require("./custom_modules/event-storage"), 5 | bodyParser = require("body-parser"), 6 | app = express(); 7 | 8 | app.engine('html', mustacheExpress()); 9 | app.set('views', './views') 10 | app.set('view engine', 'html'); 11 | app.use(express.static("./static")); 12 | app.use(bodyParser.json()); 13 | app.use(bodyParser.urlencoded({extended: true})); 14 | 15 | app.get("/api/post-update", function(req, res) { 16 | res.render("postupdate", {}); 17 | }); 18 | 19 | app.put("/api/post-update", function(req, res) { 20 | var json = req.body; 21 | json.timestamp = Date.now(); 22 | 23 | eventStorage.save(json).then(function(doc) { 24 | dataChannel.publish(JSON.stringify(json)); 25 | }, errorHandling); 26 | 27 | res.status(204).end(); 28 | }); 29 | 30 | function errorHandling(err) { 31 | throw err; 32 | } 33 | 34 | var server = app.listen(8082, function() { 35 | 36 | }); -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Realtime Demo 5 | 6 | 7 | 8 |

Realtime Demo

9 | 12 | 13 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /views/postupdate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Post Update 5 | 6 | 7 | 8 |
9 |
10 | 11 | 12 |
13 |
14 | 15 |
16 |
17 | 18 | 43 | 44 | 45 | --------------------------------------------------------------------------------