Skip to content

Instantly share code, notes, and snippets.

@defeo
Created March 13, 2017 23:34
Show Gist options
  • Save defeo/85518bbaf8b5a78375a5c8a123980a04 to your computer and use it in GitHub Desktop.
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
<!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>
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);
<!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>
'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