Created
March 13, 2017 23:34
-
-
Save defeo/85518bbaf8b5a78375a5c8a123980a04 to your computer and use it in GitHub Desktop.
Demonstration de EventSource et de WebSocket en Node.js, inspiré du TD http://defeo.lu/aws/tutorials/accounts-node
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!Doctype html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<title>EventSource demo</title> | |
<base target="_blank"> | |
</head> | |
<body> | |
<textarea style="width:100%;height:80vh" readonly></textarea> | |
<p>Liens pour connecter/déconnecter quelques utilisateurs : | |
<a href="/in/toto">Login toto</a> | |
<a href="/out/toto">Logout toto</a> | |
<a href="/in/titi">Login titi</a> | |
<a href="/out/titi">Logout titi</a> | |
</p> | |
<script> | |
var t = document.querySelector('textarea'); | |
var evt = new EventSource('/userlist'); | |
// À chaque mise à jour sur l'EventSource, on affiche les données | |
// reçues dans la zone de texte. | |
evt.addEventListener('message', function(e) { | |
t.value += 'Users: ' + e.data + '\n'; | |
}); | |
</script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var express = require('express'); | |
var evt = require('events'); | |
var app = express(); | |
// On utilise la nouvelle API Set() à la place d'une liste, pour bénéficier | |
// d'une recherche en temps linéaire | |
var userlist = new Set(); | |
// L'émetteur d'évenements global | |
var emitter = new evt.EventEmitter(); | |
// La racine sert le code du client | |
app.get('/', function(req, res) { | |
res.sendFile(__dirname + '/eventsource-client.html'); | |
}); | |
// Un ersatz de login: on ajoute dans userlist le nom d'utilisateur | |
// passé dans l'URL, et on émet un événement | |
app.get('/in/:user', function(req, res) { | |
var u = req.params.user; | |
userlist.add(u); | |
emitter.emit('login', u); | |
res.json({ ok: true }); | |
}); | |
// Un ersatz de logout | |
app.get('/out/:user', function(req, res) { | |
var u = req.params.user; | |
var success = userlist.delete(u); | |
if (success) emitter.emit('logout', u); | |
res.json({ ok: success }); | |
}); | |
// L'EventSource: à chaque événement de login ou logout envoie la liste des | |
// utilisateurs à jour | |
app.get('/userlist', function(req, res) { | |
res.set({ | |
'Content-Type': 'text/event-stream', | |
'Cache-Control': 'no-cache', | |
'Connection': 'keep-alive' | |
}); | |
res.writeHead(200); | |
// À l'ouverture de la connexion, on envoie la liste des utilisateurs | |
// Attention : JSON.stringify ne traite pas automatiquement Set() | |
res.write('data: ' + JSON.stringify(Array.from(userlist)) + '\n\n') | |
// En renvoie la liste à chaque événement | |
var listener = function(user) { | |
console.log(user, userlist); | |
res.write('data: ' + JSON.stringify(Array.from(userlist)) + '\n\n'); | |
} | |
emitter.on('login', listener); | |
emitter.on('logout', listener); | |
// Lorsque le client ferme la connexion, on détache les gestionnaires | |
// d'événement, afin de ne pas polluer le serveur. | |
req.on('close', function() { | |
emitter.removeListener('login', listener); | |
emitter.removeListener('logout', listener); | |
}) | |
}); | |
app.listen(process.env.PORT); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!Doctype html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<title>WebSocket demo</title> | |
<base target="_blank"> | |
</head> | |
<body> | |
<fieldset id="invite" disabled> | |
<input type="text" placeholder="Type the username of someone to invite" /> | |
<button>Invite</button> | |
</fieldset> | |
<button id="quit" disabled>Quit</button> | |
<p id='notification'></p> | |
<script> | |
var invite = document.querySelector('#invite'); | |
var quit = document.querySelector('#quit'); | |
var notif = document.querySelector('#notification'); | |
var ws = new WebSocket((window.location.protocol == 'https:' | |
? 'wss://' | |
: 'ws://') + window.location.host); | |
// Action d'invitation: on désactive le bouton "Invite", et on | |
// envoie un message au serveur contenant l'action. | |
invite.querySelector('button').addEventListener('click', function(e) { | |
invite.disabled = true; | |
ws.send(JSON.stringify({ | |
action: 'invite', | |
opponent: invite.querySelector('input').value, | |
})); | |
}); | |
// Action de fin de partie: on désactive le bouton "Quit", et on | |
// envoie un message au serveur contenant l'action. | |
quit.addEventListener('click', function(e) { | |
quit.disabled = true; | |
ws.send(JSON.stringify({ | |
action: 'quit', | |
})); | |
}); | |
// À chaque fois qu'on reçoit un message du serveur, on met à | |
// jour toute l'interface | |
ws.addEventListener('message', function(e) { | |
var data = JSON.parse(e.data); | |
console.log('WS message:', data); | |
switch (data.status) { | |
case 'FREE': | |
// Si FREE, on active le "Invite" et on désactive "Quit" | |
quit.disabled = !(invite.disabled = false); | |
break; | |
case 'WAITING': | |
// Si WAITING, on désactive les deux | |
quit.disabled = invite.disabled = true; | |
break; | |
case 'INVITED': | |
// Si INVITED, on affiche un popup proposant d'accepter | |
// ou refuser. | |
// On envoie un message au serveur en fonction de la | |
// réponse. | |
var accept = confirm(data.message + '. Accept?'); | |
ws.send(JSON.stringify({ | |
action: accept ? 'accept' : 'reject', | |
})) | |
break; | |
case 'PLAYING': | |
// Si PLAYING, on active Quit et on désactive Invite | |
quit.disabled = !(invite.disabled = true); | |
break; | |
default: | |
console.log('Unexpected data:', data); | |
} | |
// On affiche le message du serveur, le cas échéant. | |
notif.textContent = data.message || ''; | |
}); | |
</script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict' | |
var http = require('http'); | |
var ws = require('uws'); | |
var express = require('express'); | |
var session = require('express-session'); | |
// Variable globale contenant tous les utilisateurs connus | |
var userlist = {}; | |
// On utilise une classe ES6 pour mieux organiser le code | |
class User { | |
/* Le constructeur prend deux paramètres : | |
- name: le nom de l'utilisateur | |
- ws: un WebSocket qui permet de communiquer avec le client | |
*/ | |
constructor(name, ws) { | |
// Si un utilisateur avec ce nom est déjà présent, on donne une erreur | |
if (name in userlist) { | |
throw new Error('Already logged in'); | |
} else { | |
// On ajoute le nouvel utilisateur dans la liste globale | |
userlist[name] = this; | |
this.name = name; | |
this.ws = ws; | |
this.status = 'FREE'; | |
this.opponent = null; | |
// Lorsque le WebSocket est fermé, on enlève l'utilisateur de la | |
// liste globale, et on envoie un message aux utilisateurs engagés | |
// avec celui-ci, le cas échéant. | |
this.ws.on('close', () => { | |
console.log('WS connection closed'); | |
switch (this.status) { | |
case 'WAITING': | |
this.action_cancel(); | |
break; | |
case 'INVITED': | |
this.action_reject(); | |
break; | |
case 'PLAYING': | |
this.action_quit(); | |
break; | |
} | |
delete userlist[this.name]; | |
}); | |
// À la réception d'un message sur le WebSocket, on invoque l'un de | |
// action_invite, action_cancel, action_accept, action_reject, | |
// action_quit, en foncton du message envoyé. | |
// Si l'action demandée ne correspond à aucune de celles-ci, on | |
// envoie un message d'erreur sur le WebSocket | |
this.ws.on('message', (data) => { | |
console.log('WS message:', data); | |
data = JSON.parse(data); | |
var action = 'action_' + data.action; | |
if (!(action in this)) { | |
this.send('Action ' + data.action + ' unknown'); | |
} else { | |
this.send(this[action](data)); | |
} | |
}); | |
} | |
} | |
// Petit wrappeur permettant d'envoyer un message au client au format JSON | |
send(message) { | |
this.ws.send(JSON.stringify({ | |
name: this.name, | |
status: this.status, | |
opponent: this.opponent, | |
message: message, | |
})); | |
} | |
// Fonction d'utilité qui vérifie que l'utilisateur courant et son | |
// adversaire ont les statuts requis | |
assertStatus(opponent, mystatus, oppstatus) { | |
return this.status == mystatus | |
&& opponent !== undefined | |
&& opponent.status == oppstatus; | |
} | |
/* Ci dessous les méthodes associées aux actions des clients. | |
Chaque action prend en parametre les données envoyée par le client, met | |
à jour les états du client et de son adversaire, notifie l'adversaire le | |
cas échéant, et renvoie une chaîne de caractères représentant un message | |
à renvoyer au client. | |
*/ | |
// Action exécutée lorsque le client souhaite inviter un adversaire. | |
// Cette action ne peut être exécutée que si le client et son adversaire | |
// sont FREE. | |
action_invite(data) { | |
var opponent = userlist[data.opponent]; | |
if (this.assertStatus(opponent, 'FREE', 'FREE')) { | |
this.status = 'WAITING'; | |
opponent.status = 'INVITED'; | |
this.opponent = opponent.name; | |
opponent.opponent = this.name; | |
opponent.send('Invitation from ' + this.name); | |
return 'Invitation sent to ' + this.opponent; | |
} else { | |
return 'Cannot invite ' + opponent; | |
} | |
} | |
// Action exécutée lorsque le client souhaite annuler une invitation. | |
// Cette action ne peut être exécutée que si le client est WAITING, et son | |
// adversaire est INVITED | |
action_cancel(data) { | |
var opponent = userlist[this.opponent]; | |
if (this.assertStatus(opponent, 'WAITING', 'INVITED')) { | |
this.status = opponent.status = 'FREE'; | |
this.opponent = opponent.opponent = null; | |
opponent.send(this.name + ' cancelled the invitation'); | |
} else { | |
return 'Cannot cancel invitation to ' + opponent; | |
} | |
} | |
// Action exécutée lorsque le client souhaite accepter une invitation. | |
// Cette action ne peut être exécutée que si le client est INVITED, et son | |
// adversaire est WAITING | |
action_accept(data) { | |
var opponent = userlist[this.opponent]; | |
if (this.assertStatus(opponent, 'INVITED', 'WAITING') | |
&& opponent.opponent == this.name) { | |
this.status = opponent.status = 'PLAYING'; | |
opponent.send('Playing with ' + this.name); | |
return 'Playing with ' + this.opponent; | |
} else { | |
return 'Cannot accept invitation from ' + opponent; | |
} | |
} | |
// Action exécutée lorsque le client souhaite refuser une invitation. | |
// Cette action ne peut être exécutée que si le client est INVITED, et son | |
// adversaire est WAITING | |
action_reject(data) { | |
var opponent = userlist[this.opponent]; | |
if (this.assertStatus(opponent, 'INVITED', 'WAITING') | |
&& opponent.opponent == this.name) { | |
this.status = opponent.status = 'FREE'; | |
this.opponent = opponent.opponent = null; | |
opponent.send(this.name + ' rejected your invitation'); | |
} else { | |
return 'Cannot reject invitation from ' + opponent; | |
} | |
} | |
// Action exécutée lorsque le client souhaite quitter une partie. | |
// Cette action ne peut être exécutée que si le client et son adversaire | |
// sont PLAYING. | |
action_quit(data) { | |
var opponent = userlist[this.opponent]; | |
if (this.assertStatus(opponent, 'PLAYING', 'PLAYING')) { | |
this.status = opponent.status = 'FREE'; | |
this.opponent = opponent.opponent = null; | |
opponent.send(this.name + ' quit the game'); | |
} else { | |
return 'Cannot quit game with ' + opponent; | |
} | |
} | |
} | |
// Configuration de l'application Express | |
var app = express(); | |
// On a besoin d'une référence au mécanisme de sessions, qu'on passera au | |
// serveur WebSocket | |
var sess_storage = session({ | |
secret: "12345", | |
resave: false, | |
saveUninitialized: false, | |
}); | |
app.use(sess_storage); | |
// Le code du client est servi à l'URL /<username> | |
app.get('/:username', function(req, res) { | |
var name = req.params.username; | |
req.session.username = name; | |
res.sendFile(__dirname + '/websocket-client.html'); | |
}); | |
// Configuration du serveur WebSocket | |
var server = http.createServer(app); | |
var wsserver = new ws.Server({ | |
server: server, | |
// On passe la session Express au serveur WebSocket. | |
// On accepte les connexions websocket uniquement si le client a déjà une | |
// session active | |
verifyClient: function(info, callback) { | |
sess_storage(info.req, {}, function() { | |
callback(info.req.session.username !== undefined, 403, "Unauthorized"); | |
}); | |
}, | |
}); | |
// À la connexion d'un client | |
wsserver.on('connection', function(wsconn) { | |
// On récupére le nom de l'utilisateur de la session Express | |
var name = wsconn.upgradeReq.session.username; | |
console.log('WS connection by', name); | |
try { | |
// On crée un objet User associé | |
var user = new User(name, wsconn); | |
// On notifie le client | |
user.send(); | |
} catch (e) { | |
// Si la création n'est pas possible (utilisateur déjà connecté), | |
// on envoie une erreur et on ferme. | |
wsconn.send(JSON.stringify({ error: e.message })); | |
wsconn.close(); | |
} | |
}); | |
server.listen(process.env.PORT); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment