diff --git a/examples/cluster-haproxy/README.md b/examples/cluster-haproxy/README.md new file mode 100644 index 0000000000..ad8959656e --- /dev/null +++ b/examples/cluster-haproxy/README.md @@ -0,0 +1,31 @@ + +# Socket.IO Chat with haproxy & redis + +A simple chat demo for socket.io + +## How to use + +Install [Docker Compose](https://docs.docker.com/compose/install/), then: + +``` +$ docker-compose up -d +``` + +And then point your browser to `http://localhost:3000`. + +This will start four Socket.IO nodes, behind a haproxy instance which will loadbalance the requests (using a cookie for sticky sessions, see [cookie](https://cbonte.github.io/haproxy-dconv/1.7/configuration.html#4.2-cookie)). + +Each node connects to the redis backend, which will enable to broadcast to every client, no matter which node it is currently connected to. + +``` +# you can kill a given node, the client should reconnect to another node +$ docker-compose stop server-george +``` + +## Features + +- Multiple users can join a chat room by each entering a unique username +on website load. +- Users can type chat messages to the chat room. +- A notification is sent to all users when a user joins or leaves +the chatroom. diff --git a/examples/cluster-haproxy/docker-compose.yml b/examples/cluster-haproxy/docker-compose.yml new file mode 100644 index 0000000000..fe5aa4f96b --- /dev/null +++ b/examples/cluster-haproxy/docker-compose.yml @@ -0,0 +1,51 @@ + +haproxy: + build: ./haproxy + links: + - server-john + - server-paul + - server-george + - server-ringo + ports: + - "3000:80" + +server-john: + build: ./server + links: + - redis + expose: + - "3000" + environment: + - NAME=John + +server-paul: + build: ./server + links: + - redis + expose: + - "3000" + environment: + - NAME=Paul + +server-george: + build: ./server + links: + - redis + expose: + - "3000" + environment: + - NAME=George + +server-ringo: + build: ./server + links: + - redis + expose: + - "3000" + environment: + - NAME=Ringo + +redis: + image: redis:alpine + expose: + - "6379" diff --git a/examples/cluster-haproxy/haproxy/Dockerfile b/examples/cluster-haproxy/haproxy/Dockerfile new file mode 100644 index 0000000000..3758e18d24 --- /dev/null +++ b/examples/cluster-haproxy/haproxy/Dockerfile @@ -0,0 +1,2 @@ +FROM haproxy:1.7-alpine +COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg diff --git a/examples/cluster-haproxy/haproxy/haproxy.cfg b/examples/cluster-haproxy/haproxy/haproxy.cfg new file mode 100644 index 0000000000..547afe4441 --- /dev/null +++ b/examples/cluster-haproxy/haproxy/haproxy.cfg @@ -0,0 +1,31 @@ +# Reference: http://blog.haproxy.com/2012/11/07/websockets-load-balancing-with-haproxy/ + +global + daemon + maxconn 4096 + nbproc 2 + +defaults + mode http + balance roundrobin + option http-server-close + timeout connect 5s + timeout client 30s + timeout client-fin 30s + timeout server 30s + timeout tunnel 1h + default-server inter 1s rise 2 fall 1 on-marked-down shutdown-sessions + option forwardfor + +listen chat + bind *:80 + default_backend nodes + +backend nodes + option httpchk HEAD /health + http-check expect status 200 + cookie serverid insert + server john server-john:3000 cookie john check + server paul server-paul:3000 cookie paul check + server george server-george:3000 cookie george check + server ringo server-ringo:3000 cookie ringo check diff --git a/examples/cluster-haproxy/server/Dockerfile b/examples/cluster-haproxy/server/Dockerfile new file mode 100644 index 0000000000..d1f9075727 --- /dev/null +++ b/examples/cluster-haproxy/server/Dockerfile @@ -0,0 +1,15 @@ +FROM mhart/alpine-node:6 + +# Create app directory +RUN mkdir -p /usr/src/app +WORKDIR /usr/src/app + +# Install app dependencies +COPY package.json /usr/src/app/ +RUN npm install + +# Bundle app source +COPY . /usr/src/app + +EXPOSE 3000 +CMD [ "npm", "start" ] diff --git a/examples/cluster-haproxy/server/index.js b/examples/cluster-haproxy/server/index.js new file mode 100644 index 0000000000..c405196c06 --- /dev/null +++ b/examples/cluster-haproxy/server/index.js @@ -0,0 +1,87 @@ +// Setup basic express server +var express = require('express'); +var app = express(); +var server = require('http').createServer(app); +var io = require('socket.io')(server); +var redis = require('socket.io-redis'); +var port = process.env.PORT || 3000; +var serverName = process.env.NAME || 'Unknown'; + +io.adapter(redis({ host: 'redis', port: 6379 })); + +server.listen(port, function () { + console.log('Server listening at port %d', port); + console.log('Hello, I\'m %s, how can I help?', serverName); +}); + +// Routing +app.use(express.static(__dirname + '/public')); + +// Health check +app.head('/health', function (req, res) { + res.sendStatus(200); +}); + +// Chatroom + +var numUsers = 0; + +io.on('connection', function (socket) { + socket.emit('my-name-is', serverName); + + var addedUser = false; + + // when the client emits 'new message', this listens and executes + socket.on('new message', function (data) { + // we tell the client to execute 'new message' + socket.broadcast.emit('new message', { + username: socket.username, + message: data + }); + }); + + // when the client emits 'add user', this listens and executes + socket.on('add user', function (username) { + if (addedUser) return; + + // we store the username in the socket session for this client + socket.username = username; + ++numUsers; + addedUser = true; + socket.emit('login', { + numUsers: numUsers + }); + // echo globally (all clients) that a person has connected + socket.broadcast.emit('user joined', { + username: socket.username, + numUsers: numUsers + }); + }); + + // when the client emits 'typing', we broadcast it to others + socket.on('typing', function () { + socket.broadcast.emit('typing', { + username: socket.username + }); + }); + + // when the client emits 'stop typing', we broadcast it to others + socket.on('stop typing', function () { + socket.broadcast.emit('stop typing', { + username: socket.username + }); + }); + + // when the user disconnects.. perform this + socket.on('disconnect', function () { + if (addedUser) { + --numUsers; + + // echo globally that this client has left + socket.broadcast.emit('user left', { + username: socket.username, + numUsers: numUsers + }); + } + }); +}); diff --git a/examples/cluster-haproxy/server/package.json b/examples/cluster-haproxy/server/package.json new file mode 100644 index 0000000000..0fe83ecd3e --- /dev/null +++ b/examples/cluster-haproxy/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "socket.io-chat", + "version": "0.0.0", + "description": "A simple chat client using socket.io", + "main": "index.js", + "author": "Grant Timmerman", + "private": true, + "license": "BSD", + "dependencies": { + "express": "4.13.4", + "socket.io": "^1.7.2", + "socket.io-redis": "^3.0.0" + }, + "scripts": { + "start": "node index.js" + } +} diff --git a/examples/cluster-haproxy/server/public/index.html b/examples/cluster-haproxy/server/public/index.html new file mode 100644 index 0000000000..9b2043d73f --- /dev/null +++ b/examples/cluster-haproxy/server/public/index.html @@ -0,0 +1,28 @@ + + + + + Socket.IO Chat Example + + + + + + + + + + \ No newline at end of file diff --git a/examples/cluster-haproxy/server/public/main.js b/examples/cluster-haproxy/server/public/main.js new file mode 100644 index 0000000000..03b3702c83 --- /dev/null +++ b/examples/cluster-haproxy/server/public/main.js @@ -0,0 +1,286 @@ +$(function() { + var FADE_TIME = 150; // ms + var TYPING_TIMER_LENGTH = 400; // ms + var COLORS = [ + '#e21400', '#91580f', '#f8a700', '#f78b00', + '#58dc00', '#287b00', '#a8f07a', '#4ae8c4', + '#3b88eb', '#3824aa', '#a700ff', '#d300e7' + ]; + + // Initialize variables + var $window = $(window); + var $usernameInput = $('.usernameInput'); // Input for username + var $messages = $('.messages'); // Messages area + var $inputMessage = $('.inputMessage'); // Input message input box + + var $loginPage = $('.login.page'); // The login page + var $chatPage = $('.chat.page'); // The chatroom page + + // Prompt for setting a username + var username; + var connected = false; + var typing = false; + var lastTypingTime; + var $currentInput = $usernameInput.focus(); + + var socket = io(); + + function addParticipantsMessage (data) { + var message = ''; + if (data.numUsers === 1) { + message += "there's 1 participant"; + } else { + message += "there are " + data.numUsers + " participants"; + } + log(message); + } + + // Sets the client's username + function setUsername () { + username = cleanInput($usernameInput.val().trim()); + + // If the username is valid + if (username) { + $loginPage.fadeOut(); + $chatPage.show(); + $loginPage.off('click'); + $currentInput = $inputMessage.focus(); + + // Tell the server your username + socket.emit('add user', username); + } + } + + // Sends a chat message + function sendMessage () { + var message = $inputMessage.val(); + // Prevent markup from being injected into the message + message = cleanInput(message); + // if there is a non-empty message and a socket connection + if (message && connected) { + $inputMessage.val(''); + addChatMessage({ + username: username, + message: message + }); + // tell server to execute 'new message' and send along one parameter + socket.emit('new message', message); + } + } + + // Log a message + function log (message, options) { + var $el = $('
  • ').addClass('log').text(message); + addMessageElement($el, options); + } + + // Adds the visual chat message to the message list + function addChatMessage (data, options) { + // Don't fade the message in if there is an 'X was typing' + var $typingMessages = getTypingMessages(data); + options = options || {}; + if ($typingMessages.length !== 0) { + options.fade = false; + $typingMessages.remove(); + } + + var $usernameDiv = $('') + .text(data.username) + .css('color', getUsernameColor(data.username)); + var $messageBodyDiv = $('') + .text(data.message); + + var typingClass = data.typing ? 'typing' : ''; + var $messageDiv = $('
  • ') + .data('username', data.username) + .addClass(typingClass) + .append($usernameDiv, $messageBodyDiv); + + addMessageElement($messageDiv, options); + } + + // Adds the visual chat typing message + function addChatTyping (data) { + data.typing = true; + data.message = 'is typing'; + addChatMessage(data); + } + + // Removes the visual chat typing message + function removeChatTyping (data) { + getTypingMessages(data).fadeOut(function () { + $(this).remove(); + }); + } + + // Adds a message element to the messages and scrolls to the bottom + // el - The element to add as a message + // options.fade - If the element should fade-in (default = true) + // options.prepend - If the element should prepend + // all other messages (default = false) + function addMessageElement (el, options) { + var $el = $(el); + + // Setup default options + if (!options) { + options = {}; + } + if (typeof options.fade === 'undefined') { + options.fade = true; + } + if (typeof options.prepend === 'undefined') { + options.prepend = false; + } + + // Apply options + if (options.fade) { + $el.hide().fadeIn(FADE_TIME); + } + if (options.prepend) { + $messages.prepend($el); + } else { + $messages.append($el); + } + $messages[0].scrollTop = $messages[0].scrollHeight; + } + + // Prevents input from having injected markup + function cleanInput (input) { + return $('
    ').text(input).text(); + } + + // Updates the typing event + function updateTyping () { + if (connected) { + if (!typing) { + typing = true; + socket.emit('typing'); + } + lastTypingTime = (new Date()).getTime(); + + setTimeout(function () { + var typingTimer = (new Date()).getTime(); + var timeDiff = typingTimer - lastTypingTime; + if (timeDiff >= TYPING_TIMER_LENGTH && typing) { + socket.emit('stop typing'); + typing = false; + } + }, TYPING_TIMER_LENGTH); + } + } + + // Gets the 'X is typing' messages of a user + function getTypingMessages (data) { + return $('.typing.message').filter(function (i) { + return $(this).data('username') === data.username; + }); + } + + // Gets the color of a username through our hash function + function getUsernameColor (username) { + // Compute hash code + var hash = 7; + for (var i = 0; i < username.length; i++) { + hash = username.charCodeAt(i) + (hash << 5) - hash; + } + // Calculate color + var index = Math.abs(hash % COLORS.length); + return COLORS[index]; + } + + // Keyboard events + + $window.keydown(function (event) { + // Auto-focus the current input when a key is typed + if (!(event.ctrlKey || event.metaKey || event.altKey)) { + $currentInput.focus(); + } + // When the client hits ENTER on their keyboard + if (event.which === 13) { + if (username) { + sendMessage(); + socket.emit('stop typing'); + typing = false; + } else { + setUsername(); + } + } + }); + + $inputMessage.on('input', function() { + updateTyping(); + }); + + // Click events + + // Focus input when clicking anywhere on login page + $loginPage.click(function () { + $currentInput.focus(); + }); + + // Focus input when clicking on the message input's border + $inputMessage.click(function () { + $inputMessage.focus(); + }); + + // Socket events + + // Whenever the server emits 'login', log the login message + socket.on('login', function (data) { + connected = true; + // Display the welcome message + var message = "Welcome to Socket.IO Chat – "; + log(message, { + prepend: true + }); + addParticipantsMessage(data); + }); + + // Whenever the server emits 'new message', update the chat body + socket.on('new message', function (data) { + addChatMessage(data); + }); + + // Whenever the server emits 'user joined', log it in the chat body + socket.on('user joined', function (data) { + log(data.username + ' joined'); + addParticipantsMessage(data); + }); + + // Whenever the server emits 'user left', log it in the chat body + socket.on('user left', function (data) { + log(data.username + ' left'); + addParticipantsMessage(data); + removeChatTyping(data); + }); + + // Whenever the server emits 'typing', show the typing message + socket.on('typing', function (data) { + addChatTyping(data); + }); + + // Whenever the server emits 'stop typing', kill the typing message + socket.on('stop typing', function (data) { + removeChatTyping(data); + }); + + socket.on('disconnect', function () { + log('you have been disconnected'); + }); + + socket.on('reconnect', function () { + log('you have been reconnected'); + if (username) { + socket.emit('add user', username); + } + }); + + socket.on('reconnect_error', function () { + log('attempt to reconnect has failed'); + }); + + socket.on('my-name-is', function (serverName) { + log('host is now ' + serverName); + }) + +}); diff --git a/examples/cluster-haproxy/server/public/style.css b/examples/cluster-haproxy/server/public/style.css new file mode 100644 index 0000000000..3052d88e4c --- /dev/null +++ b/examples/cluster-haproxy/server/public/style.css @@ -0,0 +1,149 @@ +/* Fix user-agent */ + +* { + box-sizing: border-box; +} + +html { + font-weight: 300; + -webkit-font-smoothing: antialiased; +} + +html, input { + font-family: + "HelveticaNeue-Light", + "Helvetica Neue Light", + "Helvetica Neue", + Helvetica, + Arial, + "Lucida Grande", + sans-serif; +} + +html, body { + height: 100%; + margin: 0; + padding: 0; +} + +ul { + list-style: none; + word-wrap: break-word; +} + +/* Pages */ + +.pages { + height: 100%; + margin: 0; + padding: 0; + width: 100%; +} + +.page { + height: 100%; + position: absolute; + width: 100%; +} + +/* Login Page */ + +.login.page { + background-color: #000; +} + +.login.page .form { + height: 100px; + margin-top: -100px; + position: absolute; + + text-align: center; + top: 50%; + width: 100%; +} + +.login.page .form .usernameInput { + background-color: transparent; + border: none; + border-bottom: 2px solid #fff; + outline: none; + padding-bottom: 15px; + text-align: center; + width: 400px; +} + +.login.page .title { + font-size: 200%; +} + +.login.page .usernameInput { + font-size: 200%; + letter-spacing: 3px; +} + +.login.page .title, .login.page .usernameInput { + color: #fff; + font-weight: 100; +} + +/* Chat page */ + +.chat.page { + display: none; +} + +/* Font */ + +.messages { + font-size: 150%; +} + +.inputMessage { + font-size: 100%; +} + +.log { + color: gray; + font-size: 70%; + margin: 5px; + text-align: center; +} + +/* Messages */ + +.chatArea { + height: 100%; + padding-bottom: 60px; +} + +.messages { + height: 100%; + margin: 0; + overflow-y: scroll; + padding: 10px 20px 10px 20px; +} + +.message.typing .messageBody { + color: gray; +} + +.username { + font-weight: 700; + overflow: hidden; + padding-right: 15px; + text-align: right; +} + +/* Input */ + +.inputMessage { + border: 10px solid #000; + bottom: 0; + height: 60px; + left: 0; + outline: none; + padding-left: 10px; + position: absolute; + right: 0; + width: 100%; +}