En ligne maintenant : construire des systèmes de présence et de notification en temps réel
Savoir qui est en ligne et les alerter instantanément change la façon dont les gens utilisent un produit. Ce guide couvre la stack complète pour le tracking de présence et les notifications live — des événements Socket.io à l'API Browser Notifications et le push avec les service workers.
Les notifications en temps réel et la présence des utilisateurs sont des fonctionnalités essentielles des applications modernes. De l'affichage des personnes connectées dans une application de chat à la livraison d'alertes instantanées, ces fonctionnalités créent des expériences utilisateur engageantes et réactives. Ce guide couvre les fondamentaux des systèmes temps réel, puis plonge dans l'implémentation réelle de ft_transcendence — un jeu Pong multijoueur avec des demandes d'amitié, des invitations de jeu et des notifications de chat.
Fondamentaux des Notifications en Temps Réel
Avant d'implémenter les notifications, il est essentiel de comprendre les patterns architecturaux qui alimentent les systèmes temps réel. Le choix entre les modèles push et pull, combiné à l'architecture événementielle, détermine la réactivité et la scalabilité de votre système de notifications.
Modèles Push vs Pull
Les systèmes de notifications suivent fondamentalement l'un de ces deux patterns :
MODÈLE PULL (Polling) ┌────────┐ ┌────────┐ │ Client │ ─── "Des mises à jour?" ─▶ │Serveur │ │ │ ◀── "Non" ─────────────── │ │ │ │ ─── "Des mises à jour?" ─▶ │ │ │ │ ◀── "Oui : nouveau msg" ── │ │ └────────┘ └────────┘ ⚠️ Requêtes inutiles, livraison retardée MODÈLE PUSH (WebSockets) ┌────────┐ ┌────────┐ │ Client │ ════ Connexion ═══════════ │Serveur │ │ │ Persistante │ │ │ │ ◀── "Demande d'ami!" ──── │ │ │ │ ◀── "Invitation de jeu!" ─ │ │ │ │ ◀── "Nouveau message!" ── │ │ └────────┘ └────────┘ ✅ Livraison instantanée, efficace
Architecture Événementielle
Les notifications temps réel reposent sur le pattern Observer — les clients s'abonnent à des événements, et le serveur diffuse quand quelque chose se passe. Socket.io implémente ceci avec des événements nommés et un ciblage basé sur les rooms.
ARCHITECTURE DE NOTIFICATIONS ft_transcendence
┌────────────────────────────────────────────────────────────────────┐
│ NestJS Backend │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ AppGateway │ │ ChatGateway │ │ GameGateway │ │
│ │ /appGateway │ │ /chatGateway │ │ /game │ │
│ │ │ │ │ │ │ │
│ │ • Demande ami │ │ • Messages │ │ • Invitations │ │
│ │ • Block/Unblock │ │ • Join Room │ │ • Accept/Reject │ │
│ │ • Invitations │ │ • Refresh │ │ • Game Start │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └──────────┬─────────┴────────────────────┘ │
│ │ │
│ ┌────────────▼────────────┐ │
│ │ ConnectedClientsService │ ◀── Maps socketId → userId │
│ └─────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Client A │ │ Client B │ │ Client C │
│ (reçoit │ │ (reçoit │ │ (filtré │
│ notifs) │ │ notifs) │ │ dehors) │
└────────────┘ └────────────┘ └────────────┘
Système de Présence Utilisateur
Le suivi de présence répond à : « Qui est en ligne maintenant ? » et « Cet utilisateur est-il disponible pour jouer ? » Dans ft_transcendence, les utilisateurs ont trois états qui affectent le matchmaking et la disponibilité du chat.
États de Présence
| Statut | Signification | Peut Recevoir des Invitations ? |
|---|---|---|
| ONLINE | L'utilisateur est connecté et disponible | Oui |
| INAGAME | L'utilisateur joue actuellement un match | Non |
| OFFLINE | L'utilisateur s'est déconnecté | Non |
Schéma de Base de Données
enum Status {
ONLINE
OFFLINE
INAGAME
}
model User {
id String @id @default(uuid())
name String? @unique
email String @unique
Avatar String?
status Status @default(ONLINE)
// ... autres champs
}
Service de Mise à Jour de Statut
// users.service.ts
async updateUserStatus(id: string, Status: 'ONLINE' | 'OFFLINE' | 'INAGAME'): Promise<User> {
try {
return await this.prisma.user.update({
where: { id },
data: { status: Status },
});
} catch (error) {
throw new HttpException({
status: HttpStatus.INTERNAL_SERVER_ERROR,
error: 'InternalServerErrorException',
}, HttpStatus.INTERNAL_SERVER_ERROR, { cause: error });
}
}
Mise à Jour du Statut à la Déconnexion
// Sidebar.tsx - Frontend
const updateData = { status: "OFFLINE" };
const handleLogOut = async () => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_APP_URI}:3000/users/${userId}/userStatus`,
{
method: "PUT",
credentials: "include",
body: JSON.stringify(updateData),
}
);
// Effacer les cookies et rediriger...
};
Suivi des Clients Connectés
Pour envoyer des notifications ciblées, le serveur maintient une correspondance entre les IDs de Socket et les IDs d'utilisateur. Cela permet les notifications inter-gateways — par exemple, envoyer une notification de message via le gateway app quand un message de chat arrive.
// connected-clients.service.ts
export const connectedClients: Map<string, string> = new Map();
@Injectable()
export class ConnectedClientsService {
private connectedClientsInChat: Map<string, string> = new Map();
constructor(private prisma: PrismaService) {}
addClient(client: Socket) {
const userId = client.handshake.auth.userId as string;
if (userId && client.id) {
if (!connectedClients.has(client.id)) {
connectedClients.set(client.id, userId);
}
}
}
getAllClients(): Map<string, string> {
return connectedClients;
}
removeClient(client: Socket) {
connectedClients.delete(client.id);
}
addClientInchat(client: Socket) {
const userId = client.handshake.auth.userId as string;
if (userId && client.id) {
if (!this.connectedClientsInChat.has(client.id)) {
this.connectedClientsInChat.set(client.id, userId);
}
}
}
getAllClientsFromChat(): Map<string, string> {
return this.connectedClientsInChat;
}
removeClientFromChat(client: Socket) {
this.connectedClientsInChat.delete(client.id);
}
}
App Gateway — Hub de Notifications
L'App Gateway gère les notifications sociales : demandes d'amitié, blocages et invitations ciblées. Il diffuse des événements à tous les clients connectés ou cible des utilisateurs spécifiques.
// app.gateway.ts
@WebSocketGateway({
namespace: 'appGateway',
cors: { origin: `${process.env.APP_URI}:5173` },
})
export class AppGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
constructor(
private readonly usersService: UsersService,
private readonly connectedClientsService: ConnectedClientsService
) {}
@WebSocketServer() server: Server;
private logger: Logger = new Logger('APP Gateway Log');
handleConnection(client: Socket) {
this.connectedClientsService.addClient(client);
this.logger.log(`Client connecté au serveur APP : ${client.id}`);
}
@SubscribeMessage('RequestFriendShip')
handleFriendRequest(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
this.server.emit('RequestFriendShip', data);
}
@SubscribeMessage('AcceptRequest')
handleFriendCreate(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
this.server.emit('AcceptRequest', data);
}
@SubscribeMessage('DeleteFriendShip')
handleFriendShipDelete(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
this.server.emit('DeleteFriendShip', data);
}
@SubscribeMessage('BlockFriend')
handleBlockfriend(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
this.server.emit('BlockFriend', data);
}
// Invitation ciblée — notifier uniquement l'utilisateur spécifique
@SubscribeMessage('inviteUser')
async handleInviteUser(@MessageBody() payload: CreateFriendDto, @ConnectedSocket() socket: Socket) {
const user = await this.usersService.findOne(payload.userId);
for (const [key, val] of this.connectedClientsService.getAllClients()) {
if (val === payload.friendId) {
this.server.to(key).emit('invitation', `${user.name} vous a envoyé une demande d'amitié`);
}
}
await this.usersService.createFriend(payload);
}
handleDisconnect(client: Socket) {
this.connectedClientsService.removeClient(client);
this.logger.log(`Client déconnecté du serveur APP : ${client.id}`);
}
}
Chat Gateway — Notifications Inter-Namespaces
Quand un message est envoyé, le Chat Gateway notifie le destinataire via l'App Gateway. Ce pattern permet aux notifications d'apparaître même quand l'utilisateur n'est pas activement dans la vue chat.
// chat.gateway.ts
@WebSocketGateway({
namespace: 'chatGateway',
cors: { origin: `${process.env.APP_URI}:5173/chat` },
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
constructor(
private readonly chatService: ChatService,
private readonly connectedClientsService: ConnectedClientsService,
private readonly usersService: UsersService,
private prisma: PrismaService,
private appGateway: AppGateway // Injecter l'App Gateway pour les notifications inter-namespaces
) {}
@WebSocketServer() server: Server;
handleConnection(client: Socket): void {
this.connectedClientsService.addClientInchat(client);
}
@SubscribeMessage('message')
async handleEvent(@MessageBody() payload: CreateChatDto, @ConnectedSocket() socket: Socket) {
// Vérifier si l'expéditeur est bloqué par le destinataire
const blockedUser = await this.usersService.findOneBlockedUser(payload.receiverId, payload.senderId);
if (blockedUser) return;
const hashedRoomName = await this.chatService.generateHashedRoomId(payload.senderId, payload.receiverId);
if (!socket.rooms.has(hashedRoomName)) {
socket.join(hashedRoomName);
}
// Émettre aux participants de la room de chat
this.server.to(hashedRoomName).emit('getMessage', {
senderId: payload.senderId,
receiverId: payload.receiverId,
text: payload.content,
room: hashedRoomName
});
await this.chatService.createChat(payload);
// Rafraîchir la liste de chat du destinataire
for (const [key, val] of this.connectedClientsService.getAllClientsFromChat()) {
if (val === payload.receiverId) {
this.server.to(key).emit('refresh');
}
}
// Notification inter-namespaces via l'App Gateway
for (const [key, val] of this.connectedClientsService.getAllClients()) {
if (val === payload.receiverId) {
this.appGateway.server.to(key).emit('notifMessage', payload);
}
}
}
handleDisconnect(client: Socket): void {
this.connectedClientsService.removeClientFromChat(client);
}
}
Composant de Notifications Frontend
Le composant Notification écoute plusieurs événements socket et agrège les demandes d'amitié, les messages et les invitations de jeu dans un panneau de notifications unifié.
Connexion Socket
// Notification.tsx
import { io } from "socket.io-client";
import Cookies from "universal-cookie";
const cookies = new Cookies();
export const socket = io(`${process.env.NEXT_PUBLIC_APP_URI}:3000/appGateway`, {
auth: { userId: cookies.get("id") },
});
Écoute des Événements
// Notification.tsx
function Notification({ userId, userSession }: props) {
const [toggle_notif, setToggle_notif] = useState<boolean>(false);
const [notification, setNotification] = useState<string>("");
const [friendShip, setFriendShip] = useState<friendShip[]>([]);
const [GameNotificat, setGameNoticat] = useState<notification[]>([]);
const gameSocket = useSocket();
const router = useRouter();
// Écouter les événements en temps réel
useEffect(() => {
// Invitation de jeu reçue
gameSocket.on("gameInvitation", () => getGameInvitation());
// Jeu accepté — rediriger vers le jeu
gameSocket.on('AcceptedGame', data => {
const { gameId } = data;
router.push(`/game/${gameId}`);
});
// Événements de demande d'amitié
socket.on("RequestFriendShip", (data) => {
setNotification(data.userId + data.stats + data.friendId);
});
socket.on("AcceptRequest", (data) => {
setNotification(data.userId + data.stats + data.friendId);
});
socket.on("DeleteFriendShip", (data) => {
setNotification(data.userId + data.stats + data.friendId);
});
// Notification de message
socket.on("notifMessage", (data) => {
setNotification(data.senderId + data.content + data.receiverId);
});
return () => {
gameSocket.off("gameInvitation");
gameSocket.off("AcceptedGame");
socket.off("RequestFriendShip");
socket.off("AcceptRequest");
socket.off("DeleteFriendShip");
socket.off("notifMessage");
};
}, [notification]);
// ... rendu de l'UI de notification
}
Compteurs de Badge
Le badge de notification affiche un point rouge quand il y a des éléments en attente. Les messages non lus sont agrégés par expéditeur avec un compteur.
Indicateur de Badge
// Notification.tsx - Point de badge
<div className="relative" onClick={() => setToggle_notif(!toggle_notif)}>
<Image src={notification_b} width={40} alt="notifications" />
{/* Point rouge quand il y a des notifications en attente */}
{((pendingUsers && pendingUsers.length > 0) ||
(pendingMessages && pendingMessages.length > 0) ||
(GameNotificat && GameNotificat.length > 0)) && (
<div className="absolute w-[12px] h-[12px] top-1 right-1 bg-red-400 rounded-full"></div>
)}
</div>
Notifications d'Invitation de Jeu
Les invitations de jeu apparaissent dans le panneau de notification avec des actions d'acceptation/refus. L'acceptation redirige les deux joueurs vers le jeu.
// GameNotification.tsx
function Challenge({ notification, userId }: Props) {
const socket = useSocket();
return (
<div className="flex justify-between items-center">
<div className="flex items-center gap-[10px]">
<img src={notification.userImg} alt="avatar" className="rounded-full w-[40px] h-[40px]" />
<p className="text-primary font-semibold text-xs">{notification.message}</p>
</div>
<div className="flex gap-[10px] items-center">
<button
className="bg-blue-500 text-white py-[5px] px-[10px] rounded-2xl"
onClick={() => socket.emit("acceptInvitation", { invitationId: notification.id, userId })}
>
Accepter
</button>
<Image
src={close_r}
width={30}
alt="refuser"
className="cursor-pointer"
onClick={() => socket.emit("rejectInvitation", { invitationId: notification.id, userId })}
/>
</div>
</div>
);
}
Conclusion
Le système de notifications de ft_transcendence démontre des patterns clés pour les applications temps réel : gateways Socket.io multi-namespaces, transfert d'événements inter-namespaces, suivi des clients connectés et badges de notification agrégés.
Points clés :
- Séparation des namespaces : Des gateways dédiés pour l'app, le chat et le jeu gardent les préoccupations isolées
- Maps de clients connectés : Suivre les correspondances socket-utilisateur pour les notifications ciblées
- Événements inter-namespaces : Le chat gateway notifie via l'app gateway pour des notifications unifiées
- États de présence : ONLINE, OFFLINE, INAGAME contrôlent le matchmaking et la disponibilité
- Agrégation de badges : Regrouper les messages par expéditeur avec des compteurs non lus
- Actions temps réel : Les boutons accepter/refuser émettent des événements socket pour des mises à jour UI instantanées
Explorer le Code
Consultez le dépôt ft_transcendence sur GitHub pour voir l'implémentation complète du système de notifications.
Écrit par

Tech Lead et Ingénieur Full Stack pilotant une équipe de 5 ingénieurs chez Fygurs (Paris, Remote) sur un SaaS cloud-native Azure. Diplômé de 1337 Coding School (42 Network / UM6P). Écrit sur l'architecture, l'infrastructure cloud et le leadership technique.