
Objectifs
- Développer une application suivant l’architecture trois tiers, s’appuyant sur des communications via HTTP et WebSockets ;
- Comprendre les mécanismes de l’authentification (avec ou sans état) d’un client auprès d’un serveur ;
- S’initier au déploiement d’une application répartie à l’aide d’un reverse proxy.
Vue d’ensemble

Composants
- Serveur
- Client
- Infrastructure
TP 0 : Préparation de l’environnement
-
Installation de Deno :
curl -fsSL https://deno.land/install.sh | sh -
Configuration de VS Code :
- installer l’extension officielle Deno (
denoland.vscode-deno) ; - ouvrir séparément (dans des fenêtres distinctes) les répertoires du serveur et du client.
- installer l’extension officielle Deno (
-
Création des répertoires du projet :
mkdir -p ~/tp_sor/{server,client} cd ~/tp_sor/server git init cd ~/tp_sor/client git init
💡 Si vous n’avez pas l’habitude d’utiliser Git, que vous ne vous sentez pas à l’aise ou que vous avez besoin de revoir certains concepts durant les TP, reportez-vous à l’excellent Beej’s Guide to Git.
TP1 : Architecture
L’application est une plateforme de sondages en ligne. Elle permet à des utilisateurs de créer des sondages et d’ajouter des options de réponse. Les participants peuvent voter pour une ou plusieurs options selon des règles définies par le créateur du sondage. L’application gère également l’authentification des utilisateurs et assure la persistance des données.
Les acteurs de l’application sont les suivants :
- Utilisateur authentifié : peut créer des sondages, voter, et consulter les résultats ;
- Utilisateur invité : peut voter (si autorisé) et consulter les résultats (si autorisé) ;
- Administrateur : peut gérer les sondages et les utilisateurs.
Les principales fonctionnalités de l’application peuvent être résumées ainsi :
- Création de sondages avec : titre, description, date de création, date d’expiration, statut (actif/inactif) ;
- Ajout d’options à un sondage : texte descriptif ;
- Vote pour une option de sondage ;
- Consultation des résultats (nombre de votes par option) ;
- Gestion des utilisateurs (inscription, authentification).
Conception de la base de données
-
Reprendre la définition du cas d’usage ci-dessus et proposer un schéma de base de données. Donner la représentation graphique (Modèle Conceptuel de Données) du schéma (utiliser draw.io).
-
Écrire le script SQL correspondant au schéma dans un fichier
schema.sql, et le passer à SQLite pour initialiser la base de données :sqlite3 polls.db < schema.sql
Architecture du serveur
Pré-requis
-
Initialisation du projet avec Deno :
cd ~/tp_sor deno init server- Observer l’arborescence du répertoire
serverque l’on vient de créer. Quel est le point d’entrée de l’application ? - Lire la sortie de la commande d’initialisation et tester les commandes suggérées.
- Modifier l’application pour afficher
Hello, World.
- Observer l’arborescence du répertoire
-
Installation des dépendances qui seront nécessaires au fonctionnement de l’application :
cd ~/tp_sor/server deno add jsr:@oak/oak jsr:@tajpouria/cors jsr:@db/sqlite- À quoi correspond chacun des paquets de cette liste ? Trouver leur page de description et leur documentation.
- Que pouvez-vous dire sur le fichier
deno.json? Sur le fichierdeno.lock? - Où sont installées les dépendances ? Utiliser la commande
deno info.
Déroulé
-
Écrire les interfaces TypeScript nécessaires à typer les objets qui seront échangés entre la base de données, le serveur et le client. Celles-ci doivent représenter :
- un sondage ;
- une option de sondage ;
- un vote.
On doit en avoir six pour représenter les différentes “vues” sur nos données (c’est-à-dire à ce stade les objets manipulés par l’API, et les enregistrements stockés en base de données) :
- Vue API :
PollPollOptionVote
- Vue base de données :
PollRowPollOptionRowVoteRow
Ci-dessous, le squelette de l’application côté serveur (main.ts) :
import { Application, Router } from "@oak/oak";
import { oakCors } from "@tajpouria/cors";
import { DatabaseSync } from "node:sqlite";
// ---------- Database -----------------------------------
const db = new DatabaseSync("polls.db");
// ---------- HTTP Router --------------------------------
const router = new Router();
// ---------- Poll Management ----------------------------
// Create a new poll
router.post("/polls", async (ctx) => {
// TODO: validate body, create poll
});
// Get a single poll by ID
router.get("/polls/:pollId", (ctx) => {
// TODO: fetch poll from DB
});
// List all polls
router.get("/polls", (ctx) => {
// TODO: return polls list
});
// Update a poll
router.put("/polls/:pollId", async (ctx) => {
// TODO: update poll in DB
});
// Delete a poll
router.delete("/polls/:pollId", (ctx) => {
// TODO: delete poll from DB
});
// ---------- Voting -------------------------------------
// Upgrade HTTP to WebSocket
router.get("/votes/:pollId", (ctx) => {
// TODO: create vote record, increment tally, idempotency
});
// ---------- Poll Results -------------------------------
// Get aggregated results for a poll
router.get("/polls/:pollId/results", (ctx) => {
// TODO: compute and return current tally
});
// ---------- Authentication / Users ---------------------
// Register
router.post("/users/register", (ctx) => {
// TODO: register user, return token
});
// Login
router.post("/users/login", (ctx) => {
// TODO: authenticate user, return token
});
// Validate token
router.get("/users/validate", (ctx) => {
// TODO: check token validity
});
// Get user profile
router.get("/users/me", (ctx) => {
// TODO: check token validity
});
// ---------- Application --------------------------------
const PROTOCOL = "http";
const HOSTNAME = "localhost";
const PORT = 8000;
const ADDRESS = `${PROTOCOL}://${HOSTNAME}:${PORT}`;
const app = new Application();
app.use(oakCors());
app.use(router.routes());
app.use(router.allowedMethods());
app.addEventListener(
"listen",
() => console.log(`Server listening on ${ADDRESS}`),
);
if (import.meta.main) {
await app.listen({ hostname: HOSTNAME, port: PORT });
}
export { app };
-
Définir les routes qui seront nécessaires au fonctionnement de l’application. Il s’agit ici, en d’autres termes, de définir l’API de l’application (mais pas encore de programmer son fonctionnement). On peut s’inspirer de ces cinq exemples de routes, qui permettent respectivement de lister des valeurs, récupérer une valeur par son identifiant, ajouter une nouvelle valeur, mettre à jour une valeur et supprimer une valeur :
// Obtenir la liste des valeurs router.get("/values", (ctx) => {}); // Obtenir une valeur unique router.get("/values/:valueId", (ctx) => {}); // Ajouter une valeur router.post("/values", (ctx) => {}) // Modifier une valeur router.put("/values/:valueId", (ctx) => {}) // Supprimer une valeur router.delete("/values/:valueId", (ctx) => {}) -
Toute route devra retourner une réponse au client. Celle-ci peut contenir la ressource demandée, ou une erreur. Ci-dessous, voici des exemples de réponses de l’API :
// Succès { success: true, data: [ { id: "abcd" }, ], }// Erreur { success: false, error: { code: "NOT_FOUND", message: "Requested value not found", }, }Il faut représenter cette union discriminée dans le système de types. En utilisant la généricité lorsque necéssaire, écrire les interfaces, énumérations et types TypeScript nécessaires à représenter les réponses de l’API au client.
TP 2 : Développement du serveur
Rappel : on utilisera le serveur de développement fourni par Deno pour travailler sur l’application.
deno run dev
Par commidité, on peut passer dans le fichier deno.json les permissions requises par l’application. On spécifie l’appel à run dans la définition de la tâche dev :
{
"tasks": {
"dev": "deno run --watch --allow-net --allow-read --allow-write main.ts",
},
// ...
}
Routes
Le routage est le mécanisme principal d’un serveur web. Router une requête utilisateur, c’est la diriger vers la fonction appropriée pour traitement et réponse. Afin d’écrire une route, il nous faut :
- sa méthode HTTP :
GET,POST,UPDATE,DELETE, etc. ; - son chemin (la partie finale de l’adresse) : par exemple, la route
"/polls"sera atteinte à l’adressehttp://localhost:8000/polls; - sa fonction associée, c’est-à-dire le code qui sera appelé par le routeur lorsqu’il recevra une requête utilisateur sur cette route.
Pour illustrer, on trouve ci-dessous le code d’une fonction qui retourne “Hello, world!” dans le corps d’une réponse HTTP :
function sayHello(ctx: any) {
ctx.response.body = "Hello, world!"
}
On associe cette fonction en la passant au routeur pour une méthode (ici, GET) et un chemin (ici, la racine) donnés. Le routeur passera l’objet ctx à la fonction lors de son exécution :
router.get("/", sayHello);
Le contexte ctx comprend notamment les paramètres de la requête (ctx.params), la requête complète (ctx.request), ainsi qu’un objet réponse (ctx.response). Il est plus simple de passer une fonction anonyme au routeur, car l’IDE inférera le type de l’objet de ctx :
router.get("/", (ctx) => {
ctx.response.body = "Hello, world!"
});
-
Voici quelques exemples de routes qui implantent le comportement de fonctions CRUD du serveur :
// Données let values = { "foo": 42, "bar": 13.37 }; // Lister les données router.get("/values", (ctx) => { ctx.response.body = { success: true, data: values }; }); // Lister les détails d'une donnée router.get("/values/:valueId", (ctx) => { const valueId = ctx.params.valueId; if (!(valueId in values)) { ctx.response.status = 404; // Attention ! // Il faudra ici typer explicitement la réponse (erreur) de l'API ctx.response.body = { success: false, error: { code: "NOT_FOUND", message: `Value "${valueId}" not found` }, }; return; } // Attention ! // Il faudra ici typer explicitement la réponse (succès) de l'API ctx.response.body = { success: true, data: values[valueId] }; }); // Créer une nouvelle donnée router.post("/values", async (ctx) => { try { const body = await ctx.request.body.json(); } catch (err) { console.error(err); ctx.response.status = 500; // Attention ! // Il faudra ici typer explicitement la réponse (erreur) de l'API ctx.response.body = { success: false, error: { code: "SERVER_ERROR", message: "Failed to read request body" }, }; } // Attention ! // Il faudra ici valider les données envoyées par l'utilisateur values = { ...values, ...body }; ctx.response.status = 201; ctx.response.body = { success: true, data: values }; })-
Comment modifier une valeur existante ?
-
Comment supprimer une valeur de l’ensemble des données ?
-
-
Dans notre application, les valeurs manipulées par les routes ne sont pas stockées dans une variable locale mais bien dans une base de données.
-
On récupère un enregistrement unique avec :
const pollRow = db.prepare( `SELECT id, title, description, created_at, expires_at, is_active FROM polls WHERE id = ?;`, ).get(pollId); -
On récupère une liste d’enregistrements avec :
const pollOptionRows = db.prepare( `SELECT id, text, vote_count FROM poll_options WHERE poll_id = ?;`, ).all(pollId);
Ces fonctions retournent des objets, arbitraires, de type
Record<string, SQLOutputValue>. Le compilateur TypeScript ne nous laisse donc pas accéder aux champs de données définis dans nos interfaces.Écrire les fonctions permettant de convertir les enregistrements pour les sondages en base de données vers des objets exploitables dans l’API. Voici les signatures des deux fonctions :
export function pollOptionRowToApi(row: PollOptionRow): PollOption { } export function pollRowToApi(row: PollRow, optionRows: PollOptionRow[]): Poll { }Essayer de passer aux fonctions de conversion les valeurs retournées par la base de données. On obtient une erreur de type :
Argument of type 'Record<string, SQLOutputValue>' is not assignable to parameter of type 'PollRow'. Type 'Record<string, SQLOutputValue>' is missing the following properties from type 'PollRow': id, title, description, user_id, and 3 more.deno-ts(2345)Pour les utiliser, il faudra d’abord affiner le type des objets passés en paramètres des fonctions de conversion. Écrire les deux type guards suivants :
export function isPollRow(obj: Record<string, SQLOutputValue>): obj is PollRow { return ( "id" in obj && typeof obj.id === "string" && "title" in obj && typeof obj.title === "string" && "description" in obj && (typeof obj.description === "string" || obj.description === null) && // ... à compléter ); } export function isPollOptionRow(obj: Record<string, SQLOutputValue>): obj is PollOptionRow { return ( "id" in obj && typeof obj.id === "string" && "poll_id" in obj && typeof obj.poll_id === "string" && // ... à compléter ); }Attention : il faudra mettre à jour les interfaces
PollRowetPollOptionRowpour qu’elles acceptent de porter des propriétés supplémentaires arbitraires :export interface PollRow { // ... [key: string]: SQLOutputValue; // Index signature (à ajouter) } export interface PollOptionRow { // ... [key: string]: SQLOutputValue; // Index signature (à ajouter) } -
-
Coder les fonctions appelées dans les routes de l’API définies lors du TP 1.
Seules les routes concernant la gestion des sondages sont nécessaires à ce stade : lister les sondages, lister un sondage par son identifiant, créer un sondage, modifier un sondage, supprimer un sondage. Pour créer un sondage, il faudra générer son identifiant et un horodatage à la date de création :
const pollId = crypto.randomUUID(); const createdAt = new Date().toISOString();Une fois que l’on a récupéré les valeurs envoyées par le client, on peut faire l’insertion en base de données :
// On récupère le corps de la requête utilisateur, en JSON const createPollRequest = await ctx.request.body.json(); // Attention : il faut ici valider les données envoyées par le client... db.prepare( `INSERT INTO polls (id, title, description, user_id, created_at, expires_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?);`, ).run( pollId, createPollRequest.title, createPollRequest.description ?? null, null, // TODO: Authentication, cf. TP 5 createdAt, createPollRequest.expiresAt ?? null, 1, ); // Ensuite, on procède aux insertions pour les options du sondage ! // À compléter...
Test fonctionnel
-
Avec
curl:- créer un premier sondage et ses options associées ;
- tester la récupération de la liste des sondages ;
- tester la récupération d’un sondage par identifiant.
curl [-X METHOD] [PROTOCOL]://[HOSTNAME]:[PORT] \ -H "Content-Type: application/json" \ -d '{ "id": "abcd" }'
Architecture
-
Le fichier
main.tsn’a pas vocation à comprendre l’intégralité de l’application. Découper en modules les fonctionnalités principales :- Le modèle : les interfaces écrites pour le système de types de l’application ;
- Les routes : le comportement de l’application en réponse aux requêtes utilisateur.
Pour les routes, on peut définir un routeur par fichier :
// routes/polls.ts const router = new Router({ prefix: "/polls" }); // ... export default router;Et l’importer tel que :
// main.ts import pollsRouter from "./routes/polls.ts"; // ... const app = new Application(); app.use(pollsRouter.routes(), pollsRouter.allowedMethods()); // ... -
Importer les modules dans
main.ts. -
Les routes sont alourdies par la gestion des cas d’erreur :
-
leur code est englobé dans un
try/catchgénéral en cas d’erreur inattendue ; -
dans le chemin “normal”, il existe beaucoup de cas dans lesquels on retourne une erreur au client :
const responseBody: APIFailure = { success: false, error: { code: err.code, message: err.message, } }; ctx.response.status = err.status; ctx.response.body = responseBody;
Ce comportement doit être déplacé dans un middleware chargé de faire cette réponse au client. Les routes peuvent alors :
- être débarassées de leur
try/catchglobal (les erreurs inattendues seront levées par le middleware) ; - se contenter de lever une
APIExceptionen cas d’erreur, et laisser au middleware le soin de retourner cette erreur au client.
Voici une classe
APIException(vue en cours) que l’on peut utiliser :export class APIException extends Error { readonly code: APIErrorCode; readonly status: number; constructor(code: APIErrorCode, status: number, message: string) { super(message); this.code = code; this.status = status; } }Ainsi que le code du middleware à ajouter à la chaîne de traitement des requêtes pour toutes les routes :
import { Context, Next } from "@oak/oak"; import { APIErrorCode, APIException, type APIFailure } from "../model/interfaces.ts"; export async function errorMiddleware(ctx: Context, next: Next) { try { await next(); } catch (err) { if (err instanceof APIException) { const responseBody: APIFailure = { success: false, error: { code: err.code, message: err.message, } }; ctx.response.status = err.status; ctx.response.body = responseBody; console.log(responseBody); } else { console.error(err); const responseBody: APIFailure = { success: false, error: { code: APIErrorCode.SERVER_ERROR, message: "Unexpected server error", } }; ctx.response.status = 500; ctx.response.body = responseBody; } } } -
TP 3 : Client React
Pré-requis
-
Installation du bundler Vite et initialisation du projet :
cd ~/tp_sor deno init --npm vite client --template react-ts -
Création du fichier
deno.jsondans le répertoire~/tp_sor/client:{ "tasks": { "dev": "deno run -A npm:vite", "build": "deno run -A npm:vite build" }, "nodeModulesDir": "auto", "compilerOptions": { "types": [ "react", "react-dom", "@types/react" ], "lib": [ "dom", "dom.iterable", "deno.ns" ], "jsx": "react-jsx", "jsxImportSource": "react" } } -
Installation des dépendances de l’application client :
cd ~/tp_sor/client deno add npm:@deno/vite-plugin@latest npm:@types/react@latest npm:@vitejs/plugin-react@latest npm:react-router deno installOn note qu’ici, pour des questions de disponibilité, on récupère des paquets NPM plutôt que JSR. Cela entraîne la création d’un répertoire
node_moduleset d’un fichierpackage.json. -
Exécution du serveur de développement :
deno run dev
Déroulé
Le fichier src/main.tsx indique le point d’entrée de l’application :
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
Le mode strict (
StrictMode) active un ensemble de comportements utiles en phase de développement de l’application :
- chaque composant sera rendu une fois de plus que nécessaire : cela permet de vérifier l’idempotence d’un composant (étant données les mêmes entrées, un composant doit toujours retourner la même sortie). En d’autres termes, cela permet de détecter les effets de bord indésirables dans un composant impur, c’est-à-dire un composant qui produirait des modifications en-dehors de son état local (telles que muter les valeurs passées en entrée) ;
- chaque composant exécutera ses
Effectsune fois de plus que nécessaire : cela permet de détecter des bugs causés par un nettoyage manquant de l’état du composant, tels que des connexions qui resteraient ouvertes, provoquant des fuites mémoire ;- chaque composant exécutera ses callbacks
refune fois de plus que nécessaire : cela permet de détecter des bugs provenant d’une incohérence entre les références et le DOM réel ; par exemple, un accès à un élément supprimé dans le DOM de la page.Avec ce mode activé, on verra donc des requêtes HTTP en double dans les journaux, des connexions/déconnexions supplémentaires aux WebSockets, etc.
On va définir le composant App comme étant un routeur React. C’est un outil qui permet de gérer la navigation dans une application à page unique (SPA, Single-Page Application) en associant des URL à des composants :
- chaque chemin d’URL correspond à un composant React affiché ;
- le routeur écoute les changements d’URL et se charge du rendu du composant correspondant sans recharger la page ;
Il permet par ailleurs de gérer les paramètres d’URL, les redirections, les protections de route, etc.
-
Mettre en place le routeur à la racine de l’application (
App.tsx). Voici un point de départ pour créer deux routes :- à la racine, on affiche l’index de l’application (
src/pages/index.tsx) ; - au chemin
"/polls/:selectedPoll", on affiche un sondage sélectionné.
import { BrowserRouter, Route, Routes } from "react-router"; import Index from "./pages/index.tsx"; import Poll from "./pages/Poll.tsx"; import "./App.css"; function App() { return ( <BrowserRouter> <Routes> <Route path="/" element={<Index />} /> <Route path="/polls/:selectedPoll" element={<Poll />} /> </Routes> </BrowserRouter> ); } export default App; - à la racine, on affiche l’index de l’application (
-
Créer le composant
Index(src/pages/index.tsx), dans lequel on affichera la liste des sondages :-
Utiliser
useStatepour initialiser la liste des sondages (elle sera toujours vide avant la première requête vers le serveur) et définir la fonction de mise à jour de l’état du composant ; -
Utiliser
useEffectpour émetttre la requête HTTP nécessaire à récupérer les sondages depuis l’API et la passer à la fonction de mise à jour de l’état :export default function Index() { const [polls, setPolls] = ... // À compléter useEffect(() => { (async () => { const response = fetch(...) // À compléter })(); }, []);Voici deux exemples de fonctionnement de l’API
fetchpour :-
une requête
GET:async function fetch(url: string); -
une requête
POST:async function fetch(url: string, { method: "POST", body: {}, // un objet JSON });
Observer le type de
response. Observer aussi la hiérachie des interfaces. De quelles propriétés et méthodes fournies par cet objet peut-on se servir pour la récupération des données, leur affichage, et le traitement des erreurs ? -
-
Utiliser le style de programmation fonctionnel pour mapper chaque sondage à un item de liste (
<li>) dans la définition du composant :return ( <main id="content"> <h1>📊 Real-time polls</h1> <p>Click on a poll below to participate.</p> <ul> {polls.map( // À compléter // ... )} </ul> </main> );
-
-
Créer le composant
Poll(src/pages/Poll.tsx) dans lequel on affichera et interagira avec le sondage sélectionné :- S’appuyer sur les interfaces et type guards définis pour les objets de l’API : il faut copier dans le projet client les fichiers de types et de fonctions helpers issus du projet serveur ;
- Dans le composant :
-
récupérer l’identifiant du sondage sélectionné via le routeur avec
useParams:const { selectedPoll } = useParams(); -
récupérer, via
useEffect, les valeurs depuis l’API avecfetch; -
vérifier leur type avec un type guard ;
-
les afficher dans le code HTML retourné par le composant.
-
-
Adapter l’affichage du composant aux situations suivantes :
- le sondage courant n’est pas encore chargé ;
- le serveur ne répond pas ;
- le sondage demandé n’existe pas.
Pour simplifier le code du client, notamment en matière de gestion des dépendances (les effets sont déclenchés lors d’un changement d’état des variables dont ils dépendent), on peut utiliser le pattern suivant pour définir l’état d’un sondage :
type PollState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; poll: Poll }; export default function Poll() { const { selectedPoll } = useParams(); const [pollState, setPollState] = useState<PollState>({ status: "loading" }); useEffect(() => { if (!selectedPoll) return; setPollState({ status: "loading" }); (async () => { try { const resp = await fetch(`${API_URL}/polls/${selectedPoll}`); if (!resp.ok) { const json = await resp.json(); throw new Error(json.error?.message || `HTTP ${resp.status}`); } const json = await resp.json(); // Attention : il faut valider les données reçues setPollState({ status: "loaded", poll: json.data }); } catch (err) { setPollState({ status: "error", error: err instanceof Error ? err.message : "Failed to load poll", }); } })(); }, [selectedPoll]); // Dépendance à `selectedPoll` : l'effet sera déclenché à nouveau lors d'une modification de l'état du sondage }Bien sûr, à ce stade on étudie un système “distribué” composé d’un serveur et d’un client déployés sur la même machine. La latence est donc virtuellement inexistante, de même que les pertes de connexion. Pour mieux visualiser ces phénomènes côté client, on peut ajouter au serveur un middleware qui provoque des délais et des erreurs aléatoirement :
import { randomInt } from "node:crypto"; import { Context, Next } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; function delay(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } export async function entropyMiddleware(ctx: Context, next: Next) { const d10 = randomInt(0, 10); if (d10 === 9) { throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Entropy error :-)"); } if (d10 >= 3 && d10 < 9) { const timeout = randomInt(0, 5); await delay(timeout * 1000); } await next(); }On l’intègre à la chaîne de traitement d’une requête pour que la réponse échoue ou soit retardée en cas de mauvais tirage :
router.post("/", errorMiddleware, entropyMiddleware, async (ctx) => { }
TP 4 : Mécanisme de vote en direct
On propose d’utiliser l’API WebSocket pour ouvrir un canal de communication bidirectionnelle entre les applications serveur et client.
Deno offre une implantation de WebSocket, l’interface WebSocket, dans sa bibliothèque standard. On va l’utiliser côté serveur.
Interfaces
Il faut définir un langage commun pour que le serveur et le client puissent communiquer sur un WebSocket. On définit trois types de messages :
- l’envoi d’un vote (
VoteCastMessage) : envoyé par le client au serveur lors d’un vote de l’utilisateur ; - l’accusé de réception (
VoteAckMessage) : retourné par le serveur au client lorsque son vote a été traité. Il peut comporter une erreur ; - la mise à jour du nombre de votes (
VotesUpdateMessage) : diffusé par le serveur à tous les clients connectés à un sondage lors d’un changement du nombre de votes pour une option.
/**
* WebSockets
*/
// Requête : vote de l'utilisateur
export interface VoteCastMessage {
type: "vote_cast";
pollId: string;
optionId: string;
userId?: string;
}
// Réponse : accusé de réception
export interface VoteAckMessageFailure {
type: "vote_ack";
pollId: string;
optionId: string;
success: false;
error: APIError;
}
export interface VoteAckMessageSuccess {
type: "vote_ack";
pollId: string;
optionId: string;
success: true;
error?: never;
}
export type VoteAckMessage = VoteAckMessageFailure | VoteAckMessageSuccess;
// Diffusion : compteurs de votes
export interface VotesUpdateMessage {
type: "votes_update";
pollId: string;
optionId: string;
voteCount: number;
}
Ces messages peuvent être sérialisés sous forme de chaînes de caractères pour être communiqués sur un WebSocket :
// Envoi (sérialisation)
const objSrc: VoteCastMessage = {
type: "vote_cast",
pollId: "foo",
optionId: "bar",
};
const str = JSON.stringify(objSrc);
// Réception (désérialisation)
const objDst = JSON.parse(str);
Côté serveur
-
On commence par ajouter une route pour la gestion des votes (
routes/votes.ts). La route inclut l’identifiant du sondage, car les clients s’abonnent à un canal par sondage. Cela permet de recevoir les mises à jour du nombre de votes pour toutes les options d’un sondage. L’API WebSocket est événementielle : on définit des callbacks à exécuter lors de la connexion, de la réception d’un message, de la déconnexion ainsi que de la réception d’une erreur.router.get("/votes/:pollId", errorMiddleware, (ctx) => { const pollId = ctx.params.pollId; if (!pollId) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Poll not found"); } if (!ctx.isUpgradable) { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "WebSocket required"); } // On demande à HTTP de mettre à jour le protocole pour mettre en place une connexion WebSocket const ws: WebSocket = ctx.upgrade(); ws.onopen = () => { // À compléter... }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); // Il faut vérifier l'interface de `msg`... if (msg.type === "vote_cast") { // À compléter... } else { // À compléter... } }; ws.onclose = () => { // À compléter... } ws.onerror = (e) => { // À compléter... } } -
Pour garder le code de la route succinct, on délègue la gestion des WebSockets à un service (
services/vote-service.ts). Celui-ci stocke l’ensemble des connexions ouvertes dans uneMap, pour diffuser à tous les clients connectés à un sondage donné les messages de mise à jour. Les fonctionnalités du service sont données telles que :- la fonction
handleVoteMessageest appelée lors de la réception d’un message de typevote_cast. Elle appelle la fonctioncastVotequi interagit avec la base de données ; - la fonction
broadcastest appelée pour diffuser les messages de mise à jour des compteurs de votes ; - les fonctions
subscribeetunsubscribesont responsables de maintenir laMapdes connexions en cours.
// Poll ID vers liste de WebSockets uniques const subscriptions = new Map<string, Set<WebSocket>>(); // Fonction d'écriture d'un vote en base de données // Retourne le compteur de votes mis à jour pour l'option choisie function castVote( pollId: string, optionId: string, userId?: string, ): number { // ... } // Ajout d'un client à la liste des WebSockets ouverts pour un sondage donné export function subscribe(ws: WebSocket, pollId: string): void { // ... } // Retrait d'un client à la liste des WebSockets ouverts pour un sondage donné export function unsubscribe(ws: WebSocket, pollId: string): void { // ... } // Fonction appelée à la réception d'un message `vote_cast` sur un WebSocket // Retourne un message `vote_ack` au client responsable du vote // Appelle `broadcast` pour diffuser un message `votes_update` à tous les clients connectés à un sondage donné export function handleVoteMessage( ws: WebSocket, msg: VoteCastMessage, ): void { // ... } // Diffuse un message `votes_update` à tous les clients connectés à un sondage donné export function broadcast(pollId: string, message: VotesUpdateMessage): void { // ... } // Fonction utilisée pour retourner un message d'erreur au client export function sendError(ws: WebSocket, exception: APIException): void { ws.send(JSON.stringify({ type: "vote_ack", success: false, error: { code: exception.code, message: exception.message, }, })); } - la fonction
Côté client
-
Le cycle de vie du WebSocket doit être lié au cycle de vie du composant
Poll: l’instanciation du WebSocket a lieu lors du montage du composant, et sa fermeture est garantie par la fonction de nettoyage retournée paruseEffect.On met en œuvre ce mécanisme dans le composant
Poll.tsxen définissant deux callbacks qui implantent le comportement à adopter en cas de réception de messagesvote_ackou bienvotes_update:// On définit la fonction à exécuter à la réception d'un message `votes_update` const handleUpdate = useCallback((update: VotesUpdateMessage) => { setPollState((prev) => { if (prev.status !== "loaded") return prev; return { ...prev, poll: { ...prev.poll, options: prev.poll.options.map((opt) => opt.id === update.optionId ? { ...opt, voteCount: update.voteCount } : opt ), }, }; }); setAnimatingOptionId(update.optionId); }, []); // On définit la fonction à exécuter à la réception d'un accusé de réception `vote_ack` const handleAck = useCallback((ack: VoteAckMessage) => { if (!ack.success) { setVoteError(ack.error.message); } }, []); // On initialise le hook `useVoteSocket` qui se déclenchera à la réception de tout message (voir étape suivante dans le sujet) // On lui passe l'identifiant du sondage courant, et les méthodes à aossier aux deux types de message // La fonction `vote` qu'il retourne doit être utilisée dans le composant pour envoyer un vote (lorsque l'utilisateur sélectionne une option de sondage) const { vote } = useVoteSocket(selectedPoll, { onUpdate: handleUpdate, onAck: handleAck, }); -
Pour garder le code du composant succinct, on définit les effets dans un hook React (
hooks/useVoteSocket.ts) qui encapsule la gestion des communications en maintenant une référence (useRef) au WebSocket courant. Ce mécanisme permet de réagir à la réception d’un message en appelant les fonctionsonUpdateouonAck, en fonction du type de message transmis par le serveur :import { useEffect, useRef } from "react"; import type { VoteAckMessage, VotesUpdateMessage } from "../model.ts"; import { WS_URL } from "../config/api.ts"; // Définition du hook, qui prend en paramètre l'identifiant du sondage courant, et les deux fonctions à exécuter à la réception de messages du serveur (respectivement `votes_update` et `vote_ack`) export function useVoteSocket( pollId: string | undefined, { onUpdate, onAck, }: { onUpdate?: (msg: VotesUpdateMessage) => void; onAck?: (msg: VoteAckMessage) => void; }, ) { // Le hook maintient une référence au WebSocket courant const socketRef = useRef<WebSocket | null>(null); // L'effet sera déclenché en fonction de ses dépendances : // - à tout changement de sondage courant (`pollId`) : le client se connecte à/se déconnecte d'un WebSocket par sondage ; // - à tout changement des fonctions `onUpdate` et `onAck` : ces fonctions capturent l'état du composant, elles sont donc recréées à chaque rendu useEffect(() => { if (!pollId) return; // On ouvre un WebSocket sur le canal du sondage courant const ws = new WebSocket(`${WS_URL}/votes/${pollId}`); socketRef.current = ws; // Événement : lors de la réception d'un message, on exécute la fonction appropriée en fonction de son type ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === "votes_update" && onUpdate) { onUpdate(msg); } if (msg.type === "vote_ack" && onAck) { onAck(msg); } }; // Fonction de nettoyage exécutée au démontage du composant : // On déconnecte le client du WebSocket return () => { ws.close(); socketRef.current = null; }; }, [pollId, onUpdate, onAck]); // Dépendances de l'effet // Fonction retournée par le hook : envoi d'un vote // Envoi par le client d'un message `vote_cast` au serveur const vote = (optionId: string) => { // On récupère le WebSocket courant const ws = socketRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) { return { success: false, error: "Not connected" }; } // On envoie le message sur le WebSocket ws.send( JSON.stringify({ type: "vote_cast", pollId, optionId, }), ); return { success: true }; }; // Le hook retourne une fonction `vote` que l'on appelle dans le composant pour envoyer un message `vote_cast` return { vote }; }
TP 5 : Authentification
Dans ce TP, nous allons ajouter à notre application la gestion des utilisateurs et de leur authentification. Il sera possible d’empêcher l’accès à certaines fonctionnalités aux utilisateurs qui ne seraient pas connectés.
Pour cela, on utilisera un mécanisme de jeton de connexion, JWT (pour JSON Web Tokens).
On commence par détailler l’ensemble des structures de données que l’on va manipuler, aussi bien côté serveur et que côté client, pour la fonctionnalité d’authentification.
Voici les interfaces partagées entre le serveur et le client :
User: un utilisateur (côté serveur, il faut aussi son pendant base de données,UserRow) ;LoginRequest: Data Transfer Object (DTO) qui contient les informations requises pour une connexion utilisateur ;RegisterRequest: DTO qui contient les informations requises pour l’enregistrement d’un nouvel utilisateur ;AuthResponse: réponse du serveur en cas d’authentification réussie ; comporte une structureUserainsi qu’un jeton JWT.
Côté serveur :
AuthPayload: le contenu décodé du jeton, signé cryptographiquement. On y retrouve l’identifiant unique de l’utilisateur, son nom, son niveau de permissions ainsi qu’un champexpdonnant la date d’expiration du jeton.
/**
* Authentification
*/
export interface User {
id: string;
username: string;
isAdmin: boolean;
createdAt: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
isAdmin?: boolean;
}
export interface AuthResponse {
token: string;
user: User;
}
// Côté serveur
export interface AuthPayload {
userId: string;
username: string;
isAdmin: boolean;
exp: number;
}
Côté serveur
Plusieurs nouveaux fichiers sont nécessaires pour gérer l’authentification côté serveur :
lib/jwt.ts: module de gestion des jetons (création et vérification d’un jeton) et des mots de passe (hashage et vérification d’un mot de passe). S’appuie sur la bibliothèque cryptographique de Node ;middleware/auth.ts: ajouté à la chaîne de middlewares des routes à protéger, il vérifie la présence et la validité de l’en-têteAuthorizationdes requêtes reçues. Si le jeton transmis par le client est valide, la requête est validée et son contexte est augmenté des informations de l’utilisateur ;routes/users.ts: définit les routes de gestion des utilisateurs (CRUD) ainsi que de connexion et déconnexion.
-
Compléter la fonction
verifyJWTdu modulejwt.tsdonné ci-dessous :import { randomBytes, scrypt } from "node:crypto"; import { jwtVerify, SignJWT } from "@panva/jose"; import { type AuthPayload, isAuthPayload } from "../model/interfaces.ts"; const JWT_SECRET = "tp-M1-SOR-2026"; const JWT_KEY = new TextEncoder().encode(JWT_SECRET); // Crée un jeton d'authentification // Le jeton est hashé avec l'algorithme HMAC avec SHA-256 et une clef secrète // Le jeton est valide pendant 24 heures et attribué à l'utilisateur contenu dans `payload` export async function createJWT( payload: Omit<AuthPayload, "exp">, ): Promise<string> { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("24h") .sign(JWT_KEY); } // Passe le jeton à la fonction `verify` de la bibliothèque de JSON Web Tokens `djwt` // Valide le type de l'objet retourné par `verify`, qui doit être conforme à `AuthPayload` // Retourne le payload s'il est valide, `null` sinon export async function verifyJWT(token: string): Promise<AuthPayload | null> { // À compléter... } // Produit le hash d'un mot de passe donné en paramètre // Format : hash.salt export function hashPassword(password: string): Promise<string> { const salt = randomBytes(16).toString("hex"); return new Promise((resolve, reject) => { scrypt(password, salt, 64, (err, derivedKey) => { if (err) reject(err); else resolve(`${derivedKey.toString("hex")}.${salt}`); }); }); } // Compare le mot de passe et le hash passés en paramètres, en ré-hashant le mot de passe export function verifyPassword( password: string, storedHash: string, ): Promise<boolean> { const [hash, salt] = storedHash.split("."); return new Promise((resolve, reject) => { scrypt(password, salt, 64, (err, derivedKey) => { if (err) reject(err); else resolve(hash === derivedKey.toString("hex")); }); }); } -
…
import { Context, Next, State } from "@oak/oak"; import { verifyJWT } from "../lib/jwt.ts"; import { APIErrorCode, type AuthPayload } from "../model/interfaces.ts"; // ... export interface AuthContext extends Context { state: AuthState; } // ... export interface AuthState extends State { user?: AuthPayload; } // ... export async function authMiddleware(ctx: AuthContext, next: Next) { // ... const authHeader = ctx.request.headers.get("Authorization"); // ... if (!authHeader || !authHeader.startsWith("Bearer ")) { ctx.response.status = 401; ctx.response.body = { success: false, error: { code: APIErrorCode.UNAUTHORIZED, message: "Missing or invalid token", }, }; return; } // ... const token = authHeader.substring(7); const payload = await verifyJWT(token); // ... if (!payload) { ctx.response.status = 401; ctx.response.body = { success: false, error: { code: APIErrorCode.UNAUTHORIZED, message: "Invalid token" }, }; return; } // ... ctx.state.user = payload; // ... await next(); } -
…
// ... router.post("/register", async (ctx) => { // ... } // ... router.post("/login", async (ctx) => { // ... } // ... router.get("/me", authMiddleware, (ctx: AuthContext) => { // ... }
Côté client
Pour bien comprendre la séparation des responsabilités, on crée plusieurs fichiers dans l’application React :
contexts/AuthContext.ts: “conteneur de l’état”, fournit l’interface qui définit le contexte d’identification courant (c’est-à-dire l’utilisateur et le token retournés par le serveur après une connexion réussie). Permet d’accéder à l’état du contexte, et retourne une fonction qui permet de le modifier ;contexts/AuthProvider.tsx: composant wrapper qui englobe toute l’application et qui maintient effectivement l’état de ce contexte (en faisant appel àuseState). Il est responsable de passer l’état de l’authentification à tous les composants enfants ;hooks/useAuth.ts: “accesseur / mutateur de l’état”, lit le contexte d’authentification et fournit les méthodes pour agir dessus (login,logout). Fournit également une fonction helperauthFetchqui ajoute l’en-têteAuthorizationaux requêtes HTTPfetchvers le serveur nécessitant d’être authentifié ;pages/Login.tsx: composant point d’entrée pour la connexion d’un utilisateur : affiche un formulaire (nom d’utilisateur, mot de passe), envoie la requête de connexion au serveur, reçoitAuthResponseet appelleloginpour mettre à jour le contexte d’authentification dans toute l’application ;pages/Restricted.tsx: composant wrapper qui englobe toute page de l’application accessible seulement par les utilisateurs authentifiés. Bloque la navigation si l’utilisateur n’est pas connecté.
- …
- …
- Créer un composant pour la connexion d’un utilisateur
Nouvelles fonctionnalités
TODO: Grâce à l’ajout des utilisateurs et de l’authentification, on peut intégrer de nouvelles fonctionnalités à l’application
- Ajouter la possibilité de restreindre le vote aux utilisateurs connectés lors de la création d’un sondage
TP 6 : Déploiement
mkcert
# Ajouter un répertoire local au PATH
mkdir -p ~/.local/bin
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.bashrc
source ~/.bashrc
# Installer mkcert
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64
mv mkcert-v*-linux-amd64 ~/.local/bin
# Générer un certificat pour le domaine coucou.localhost
mkcert coucou.localhost
- Dans Firefox : Paramètres > Vie privée et sécurité > Afficher les certificats
- Onglet “Autorités” > Importer
- Dossier personnel > Clic droit > Afficher les fichiers cachés
- Se déplacer dans ~/.local/share/mkcert
- Choisir le fichier rootCA.pem
- Cocher “Confirmer cette AC pour identifier des sites web”
- Valider avec OK
- Relancer Firefox
- Exécuter le script suivant :
const listener = Deno.listenTls({
port: 4443,
hostname: "coucou.localhost",
cert: await Deno.readTextFile("coucou.localhost.pem"),
key: await Deno.readTextFile("coucou.localhost-key.pem"),
});
console.log(`https://coucou.localhost:4443`);
for await (const conn of listener) {
const httpConn = Deno.serveHttp(conn);
for await (const requestEvent of httpConn) {
requestEvent.respondWith(new Response("Hello world"));
}
}
- Ouvrir la page https://coucou.localhost:4443 dans Firefox
- Constater qu’il n’y a pas d’avertissement de sécurité
nginx
-
Télécharger nginx et l’ajouter au PATH :
curl -L https://github.com/jirutka/nginx-binaries/raw/refs/heads/binaries/nginx-1.28.1-x86_64-linux -o ~/.local/bin/nginx chmod +x ~/.local/bin/nginx -
Écrire la configuration dans
nginx.conf
TP 7 : Performances et fiabilité
Jeux de tests
-
Tests unitaires côté serveur
-
Tests end-to-end (E2E) côté client
Profilage
-
Profilez le fonctionnement de votre application
-
Analysez le fichier résultat dans cpupro
Injection de trafic
- Installer JMeter
TP 8 : Améliorations
- Gestion de l’état du composant :
- Chargement
- Erreur
- Contraintes :
- Limite sur la fréquence de vote
- Ajouter un compteur du temps restant au sondage sur la page d’un sondage
- …
- Présentation des résultats
- Interface de création d’un sondage
- Interface de gestion d’un sondage
- Accès aux sondages par lien public
- Génération d’un QR Code
- Accès protégé par mot de passe
- Type de sondage : dates
- Type de sondage : cagnotte