Internet of Shit

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
    • Bundler : Vite [doc]
    • Framework : React (en TypeScript) [doc]
    • Outillage : React Developer Tools (pour le navigateur) [doc]
  • Infrastructure
    • Base de données : SQLite [doc]
    • Reverse proxy : nginx [doc]
    • Certificats SSL : mkcert [doc]

TP 0 : Préparation de l’environnement

  1. Installation de Deno :

    curl -fsSL https://deno.land/install.sh | sh
    
  2. 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.
  3. 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

  1. 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).

  2. É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

  1. Initialisation du projet avec Deno :

    cd ~/tp_sor
    deno init server
    
    • Observer l’arborescence du répertoire server que 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.
  2. 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 fichier deno.lock ?
    • Où sont installées les dépendances ? Utiliser la commande deno info.

Déroulé

  1. É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 :
      • Poll
      • PollOption
      • Vote
    • Vue base de données :
      • PollRow
      • PollOptionRow
      • VoteRow

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 };
  1. 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) => {})
    
  2. 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’adresse http://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!"
});
  1. 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 ?

  2. 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 PollRow et PollOptionRow pour 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)
    }
    
  3. 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

  1. 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

  1. Le fichier main.ts n’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());
    // ...
    
  2. Importer les modules dans main.ts.

  3. Les routes sont alourdies par la gestion des cas d’erreur :

    • leur code est englobé dans un try/catch gé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/catch global (les erreurs inattendues seront levées par le middleware) ;
    • se contenter de lever une APIException en 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

  1. Installation du bundler Vite et initialisation du projet :

    cd ~/tp_sor
    deno init --npm vite client --template react-ts
    
  2. Création du fichier deno.json dans 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"
      }
    }
    
  3. 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 install
    

    On 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_modules et d’un fichier package.json.

  4. 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 Effects une 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 ref une 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.

  1. 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;
    
  2. Créer le composant Index (src/pages/index.tsx), dans lequel on affichera la liste des sondages :

    1. Utiliser useState pour 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 ;

    2. Utiliser useEffect pour é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 fetch pour :

      • 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 ?

    3. 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>
      );
      
  3. 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 :
      1. récupérer l’identifiant du sondage sélectionné via le routeur avec useParams :

        const { selectedPoll } = useParams();
        
      2. récupérer, via useEffect, les valeurs depuis l’API avec fetch ;

      3. vérifier leur type avec un type guard ;

      4. les afficher dans le code HTML retourné par le composant.

  4. 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

  1. 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...
      }
    }
    
  2. 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 une Map, 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 handleVoteMessage est appelée lors de la réception d’un message de type vote_cast. Elle appelle la fonction castVote qui interagit avec la base de données ;
    • la fonction broadcast est appelée pour diffuser les messages de mise à jour des compteurs de votes ;
    • les fonctions subscribe et unsubscribe sont responsables de maintenir la Map des 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,
        },
      }));
    }
    

Côté client

  1. 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 par useEffect.

    On met en œuvre ce mécanisme dans le composant Poll.tsx en définissant deux callbacks qui implantent le comportement à adopter en cas de réception de messages vote_ack ou bien votes_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,
    });
    
  2. 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 fonctions onUpdate ou onAck, 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 };
    }