Temps réel avec Socket.io
Dans ce chapitre, on continue de construire notre Show Watchlist en ajoutant des notifications en temps réel : les utilisateurs voient live quand quelqu'un bascule le statut vu d'une série.
HTTP vs WebSocket
Les appels API classiques suivent un modèle requête/réponse : le client demande, le serveur répond, la connexion se ferme. Ce modèle ne convient pas aux fonctionnalités en temps réel (jeu en ligne, chat, notifications...) car le serveur ne peut pas pousser de données sans que le client ne les demande.
WebSocket maintient une connexion bidirectionnelle persistante entre le client et le serveur. Les deux parties peuvent envoyer des messages à tout moment.
Socket.io est une librairie qui simplifie l'utilisation des WebSockets en ajoutant :
- Reconnexion automatique
- Gestion des événements nommés
- Compatibilité navigateurs
- Authentification via token
HTTP (REST) WebSocket / Socket.io
────────────────── ──────────────────────────────
Client → Serveur (requête) Client ←→ Serveur (connexion persistante)
Serveur → Client (réponse) Événements dans les deux sens
Connexion fermée Connexion maintenue ouverte
Installation
npm install socket.io-client
Connexion au serveur
import { io } from 'socket.io-client'
const socket = io('http://localhost:3001', {
auth: { token: 'eyJ...' }, // Token JWT pour l'authentification
})
socket.on('connect', () => {
console.log('Connecté :', socket.id)
})
socket.on('disconnect', () => {
console.log('Déconnecté')
})
Émettre et écouter des événements
// Émettre un événement vers le serveur
socket.emit('toggleSeen', { movieId: 42, title: 'Inception' })
// Écouter un événement du serveur
socket.on('watchlistUpdated', (data) => {
console.log(`${data.username} vient de marquer "${data.title}" comme vu`)
})
// Supprimer un listener
socket.off('watchlistUpdated')
Intégration dans un store Pinia
La bonne pratique est d'encapsuler la logique Socket.io dans un store Pinia. Le store gère la connexion, les événements et l'état des notifications. Les composants n'interagissent qu'avec le store.
// src/store/notifications.ts
import { defineStore } from 'pinia'
import { io, type Socket } from 'socket.io-client'
import { computed, ref } from 'vue'
interface WatchlistNotification {
username: string
title: string
movieId: number
at: string
}
export const useNotificationsStore = defineStore('notifications', () => {
const socket = ref<Socket | null>(null)
const socketId = ref<string | null>(null)
const notifications = ref<WatchlistNotification[]>([])
const error = ref<string | null>(null)
const isConnected = computed(() => !!socketId.value)
// ─── Connexion ───────────────────────────────────────────────
const connect = (token: string): void => {
if (socket.value?.connected) return
const s = io(import.meta.env.VITE_SOCKET_URL, {
auth: { token },
})
socket.value = s
// Événements système
s.on('connect', () => {
socketId.value = s.id ?? null
error.value = null
})
s.on('disconnect', () => {
socketId.value = null
})
s.on('connect_error', (err: Error) => {
error.value = err.message
})
// Événements métier : notification quand un autre utilisateur bascule un film
s.on('watchlistUpdated', (data: WatchlistNotification) => {
notifications.value.unshift(data)
})
s.on('error', (data: { message: string }) => {
error.value = data.message
})
}
const disconnect = (): void => {
socket.value?.disconnect()
socket.value = null
socketId.value = null
error.value = null
}
// ─── Actions ─────────────────────────────────────────────────
const notifyToggleSeen = (movieId: number, title: string): void => {
socket.value?.emit('toggleSeen', { movieId, title })
}
return {
socketId,
notifications,
error,
isConnected,
connect,
disconnect,
notifyToggleSeen,
}
})
Utiliser le store dans un composant
On intègre les notifications dans HomePage : à chaque toggleSeen, on émet un événement socket. Les autres utilisateurs connectés reçoivent la notification via watchlistUpdated.
onUnmounted est le symétrique de onMounted — il s'exécute quand le composant est détruit. C'est ici qu'on nettoie la connexion socket pour éviter les fuites mémoire.
<!-- src/pages/HomePage.vue -->
<template>
<div>
<p v-if="socketError">Erreur socket : {{ socketError }}</p>
<!-- Notifications en temps réel -->
<NCard v-if="notifications.length" title="Activité récente">
<NSpace vertical>
<p v-for="(notif, i) in notifications" :key="i">
<strong>{{ notif.username }}</strong> vient de marquer
<em>{{ notif.title }}</em> comme vu
</p>
</NSpace>
</NCard>
<p v-if="isLoading">Chargement...</p>
<p v-else-if="error">{{ error }}</p>
<ShowList v-else :shows="shows" @toggle-seen="handleToggleSeen">
<NSpace justify="space-between" align="center">
<NInput v-model:value="search" placeholder="Rechercher une série..."/>
<NTag type="success">{{ seenShows.length }} vu(s) sur {{ shows.length }}</NTag>
</NSpace>
</ShowList>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useShows } from '@/composables/useShows'
import { useNotificationsStore } from '@/store/notifications'
import { useAuthStore } from '@/store/auth'
import ShowList from '@/components/ShowList.component.vue'
import type { Show } from '@/types'
const search = ref<string>('')
const { shows, seenShows, isLoading, error, toggleSeen } = useShows()
const notificationsStore = useNotificationsStore()
const authStore = useAuthStore()
const { notifications, error: socketError } = storeToRefs(notificationsStore)
watch(search, (newVal) => {
console.log('Recherche :', newVal)
})
onMounted(() => {
if (authStore.token) {
notificationsStore.connect(authStore.token)
}
})
onUnmounted(() => {
notificationsStore.disconnect()
})
const handleToggleSeen = (show: Show) => {
toggleSeen(show)
notificationsStore.notifyToggleSeen(show.id, show.name)
}
</script>
Nettoyer les connexions
Toujours se déconnecter dans onUnmounted pour éviter les fuites mémoire et les listeners orphelins :
onUnmounted(() => {
notificationsStore.disconnect()
})