Séance 6

Asynchrone et réseau

Rémi Sharrock

TypeScript 101, Télécom Paris

Échauffement

Trois questions sur la séance 5, tirées des QCM du cours.

Question 1 (chapitre 5.1)

Que se passe-t-il ?

const data = JSON.parse('{"name":"Joe"}');
console.log(data.nom.toUpperCase());
  • A. Une erreur de compilation : nom n’existe pas
  • B. Le code compile, puis plante à l’exécution
  • C. Il affiche JOE
  • D. Il affiche undefined

B. JSON.parse renvoie any : plus rien n’est vérifié.

Question 2 (chapitre 5.2)

Quel est le type de a ?

function premier<T>(tab: T[]): T | undefined {
  return tab[0];
}

const a = premier(["x", "y"]);
  • A. any
  • B. string | undefined
  • C. T
  • D. string[]

B. TypeScript déduit T = string ; le tableau pourrait être vide.

Question 3 (chapitre 5.3)

Qu’affiche ce code ?

try {
  console.log("A");
  throw new Error("oups");
  console.log("B");
} catch (e) {
  console.log("C");
} finally {
  console.log("D");
}
  • A. A B C D · B. A C D · C. A C · D. A D

B. throw interrompt le try ; finally s’exécute dans tous les cas.

Objectifs

  • Expliquer pourquoi JavaScript a besoin d’asynchronisme
  • Décrire une Promise et prédire un ordre d’exécution
  • Écrire async / await et traiter les erreurs
  • Appeler une API avec fetch et typer le résultat
  • Reconnaître une erreur CORS et la contourner légitimement

async / await

Un seul fil d’exécution

En C, read() bloque votre programme, et le système donne la main à un autre processus. JavaScript ne fonctionne pas ainsi.

Dans un navigateur, tout votre code s’exécute sur un seul fil : celui qui redessine la page et réagit aux clics. Si une fonction bloque deux secondes :

  • la page ne se redessine plus
  • les clics ne produisent rien
  • l’onglet paraît planté

Une requête réseau met 200 ms, parfois 5 secondes. Bloquer n’est pas une option.

La Promise

Un objet qui représente un résultat de type T qui arrivera plus tard, ou qui n’arrivera jamais.

État Anglais Signification
en attente pending l’opération est en cours
tenue fulfilled réussite, la promesse porte une valeur
rompue rejected échec, la promesse porte une erreur

Une promesse part de pending et change d’état une seule fois, définitivement. Le type entre chevrons est celui de la valeur finale : Promise<string> signifie « une chaîne, plus tard ».

await : attendre sans bloquer

const p: Promise<string> = fetchData();   // p est une Promise<string>
const data: string = await p;             // data est une string

Dire qu’await « met en pause » est exact, mais il faut préciser quoi.

await suspend uniquement la fonction qui l’exécute. Le fil n’est pas bloqué : il part faire autre chose et reviendra terminer la fonction.

C’est toute la différence avec un sleep() en C, qui bloque tout.

async : la contrepartie

await ne s’écrit que dans une fonction async, ou à la racine d’un module ES, ce qui inclut le frontmatter d’un fichier Astro.

async function fetchData(): Promise<string> {
  await delay(2000);
  return "Some data";     // on écrit une string
}                         // l'appelant reçoit une Promise<string>

Une fonction async retourne toujours une promesse, que vous le vouliez ou non.

Oubliez le await à l’appel, et vous affichez Promise { <pending> }.

L’ordre d’exécution

async function fetchData(): Promise<string> {
  console.log("[2] début de fetchData");
  await delay(2000);
  console.log("[4] les 2 secondes sont écoulées");
  return "Some data";
}

console.log("[1] avant l'appel");
fetchData().then((data) => console.log("[5] reçu :", data));
console.log("[3] après l'appel");

Prédisez la sortie avant de tourner la page.

La sortie

[1] avant l'appel
[2] début de fetchData
[3] après l'appel
   ... deux secondes s'écoulent ...
[4] les 2 secondes sont écoulées
[5] reçu : Some data
  1. Le code à l’intérieur démarre tout de suite : [2] précède [3]
  2. Au premier await, la fonction rend la main : le fil reste libre
  3. [4] et [5] arrivent quand la promesse est tenue

[3] s’exécute alors que fetchData n’est pas terminée. Pour attendre réellement, il faut await fetchData(), donc être soi-même dans une async.

Fabriquer une promesse

Le plus souvent vous consommez des promesses. Il arrive d’en créer une, pour emballer une ancienne API à callback.

function readFile(filename: string): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.5) {
        resolve("Lecture OK");
      } else {
        reject(new Error("Erreur de lecture !"));
      }
    }, 2000);
  });
}

resolve(v) fait passer à fulfilled, reject(e) à rejected.

.then() ou await ?

readFile("x.txt")
  .then((retour) => console.log(retour))
  .catch((error) => console.error(error.message));
async function lire(f: string): Promise<void> {
  try {
    const retour: string = await readFile(f);
    console.log(retour);
  } catch (error) {
    console.error("Lecture impossible :", error);
  }
}

Préférez async / await : le code se lit de haut en bas, et un seul try / catch gère les erreurs synchrones et asynchrones.

Pas de await, pas de catch

try {
  readFile("x.txt");   // pas de await : la promesse part, on ne l'attend pas
} catch (e) {
  // jamais exécuté
}

Le bloc try se termine avant que la promesse ne soit rompue. Le rejet devient une unhandled promise rejection.

Retenez la règle

Pas de await, pas de catch.

Promise.all

const a = await charger("/a");   // 2 s
const b = await charger("/b");   // 2 s
const c = await charger("/c");   // 2 s, total : 6 s

Chaque await attend le précédent, alors que les requêtes sont indépendantes.

const [a, b, c] = await Promise.all([
  charger("/a"), charger("/b"), charger("/c"),
]);                              // total : 2 s

Résultats dans l’ordre des promesses fournies. Si une seule est rompue, tout l’ensemble l’est. Sinon, utilisez Promise.allSettled.

fetch

Qu’est-ce que fetch ?

La fonction standard du navigateur (et de Node.js depuis la version 18) pour envoyer une requête HTTP.

fetch(url)   // type : Promise<Response>

Elle ne retourne pas les données. Elle retourne une Response : un objet qui décrit la réponse HTTP et qui sait, à la demande, lire le corps du message.

La double attente

const response: Response = await fetch(url);   // 1er await : les en-têtes
const data = await response.json();            // 2e await : le corps entier

Une réponse HTTP n’arrive pas d’un bloc. Le premier await se termine dès les en-têtes reçus ; le corps peut faire plusieurs mégaoctets.

Méthode Retourne Usage
response.json() Promise<any> corps JSON, le cas courant
response.text() Promise<string> HTML, CSV, texte brut
response.blob() Promise<Blob> image, fichier binaire

Le corps ne se lit qu’une seule fois : sinon body stream already read.

fetch ne rejette pas sur un 404

L’erreur la plus coûteuse du chapitre

La promesse de fetch est tenue dès que le serveur a répondu, même avec un 404 ou un 500. Du point de vue de fetch, « le serveur a répondu 404 » est un succès de communication.

Elle n’est rompue que si la requête n’a pas pu aboutir : DNS, câble, blocage CORS.

const response = await fetch(url);
if (!response.ok) {
  throw new Error(`HTTP ${response.status} ${response.statusText}`);
}

response.ok vaut true si et seulement si le statut est entre 200 et 299.

Typer ce qu’on reçoit

interface Earthquake {
  properties: { time: number; place: string; mag: number };
}

async function getEarthquakes(): Promise<Earthquake[]> {
  const response = await fetch(URL_USGS);
  if (!response.ok) {
    throw new Error(`USGS a répondu ${response.status}`);
  }
  const json = await response.json();
  return json.features;
}

On ne décrit que les champs utilisés, pas tout le GeoJSON. Annoter le retour Promise<Earthquake[]> est ce qui donne sa valeur au code.

Une interface n’est pas une garantie

Le type n’existe qu’à la transpilation

Si l’API change son format, TypeScript ne s’en apercevra pas : votre code plantera à l’exécution.

L’interface documente et protège votre code. Elle ne valide pas les données entrantes : pour cela, il faut un prédicat de type, comme au chapitre 5.1.

Afficher dans la page

Méthode Rôle
document.createElement("ul") crée un élément, détaché de la page
element.textContent = "..." définit le texte
parent.appendChild(enfant) insère dans la page
const ul = document.createElement("ul");      // AVANT la boucle
earthquakes.forEach((e) => {
  const li = document.createElement("li");    // DANS la boucle
  li.textContent = `${e.properties.mag} : ${e.properties.place}`;
  ul.appendChild(li);
});
document.body.appendChild(ul);                // APRÈS la boucle

textContent et non innerHTML : la donnée vient d’une API.

CORS

Une origine

Le triplet schéma + hôte + port. Deux URL ont la même origine si et seulement si les trois sont identiques.

Comparée à https://exemple.fr/a Même origine ? Pourquoi
https://exemple.fr/b/c oui le chemin ne compte pas
http://exemple.fr/a non schéma différent
https://api.exemple.fr/a non un sous-domaine est un autre hôte
https://exemple.fr:8080/a non port différent

Votre Astro sur localhost:4321 et votre json-server sur localhost:3000 sont deux origines distinctes.

La same-origin policy

Un navigateur autorise une page à envoyer une requête vers une autre origine, mais lui interdit d’en lire la réponse.

Le scénario qui la justifie : vous êtes connecté à votre banque dans un onglet. Sans cette politique, le JavaScript d’un autre site pourrait faire fetch("https://mabanque.fr/api/comptes"), avec vos cookies de session, et exfiltrer vos relevés.

CORS est le mécanisme standard qui permet à un serveur de lever sélectivement cette restriction, pour les origines de son choix.

Lire l’erreur

Access to fetch at 'https://api.deezer.com/genre/' from origin
'http://localhost:4321' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present.
  1. Ce n’est pas un bug de votre code. Le même fetch marche depuis Node.js
  2. Le serveur a répondu normalement. C’est le navigateur qui refuse
  3. L’erreur remonte comme un échec réseau. Ni status, ni corps à inspecter

curl n’est pas concerné

curl https://api.deezer.com/genre/    # fonctionne parfaitement

CORS est une règle appliquée par les navigateurs, pas par les serveurs. curl, Node.js, Python, Postman n’ont aucune notion d’origine.

Conséquence : CORS ne protège pas les données du serveur. Il protège l’utilisateur du navigateur contre les sites qui agiraient en son nom.

Ce que le serveur doit envoyer

Access-Control-Allow-Origin: https://monsite.fr
Access-Control-Allow-Origin: *              (API ouverte à tous)

Pour les requêtes non triviales (PUT, DELETE, en-tête Authorization, Content-Type: application/json), le navigateur envoie d’abord une requête OPTIONS de preflight.

Le serveur doit alors répondre en plus avec Access-Control-Allow-Methods et Access-Control-Allow-Headers.

La solution : le proxy

Situation Solution
L’API est à vous ajoutez Access-Control-Allow-Origin
L’API est publique et documentée rien à faire
L’API refuse les origines externes requête côté serveur

Deux fausses solutions

Une extension « CORS unblock » ou Chrome lancé avec --disable-web-security désactivent une protection pour tous les sites que vous visitez : messagerie et banque comprises. Et votre application reste cassée chez les autres.

Bénéfice annexe du proxy : la clé d’API reste sur le serveur.

Astro : appeler côté serveur

« Côté serveur », dans Astro, signifie : dans le frontmatter, entre les deux lignes ---.

---
const response = await fetch('https://api.deezer.com/genre/');
const genreJson: GenreAPI = await response.json();

const serverData = {
  total: genreJson.data.length,
  names: genreJson.data.slice(0, 5).map((g) => g.name),
};
---
<p>Genres trouvés : {serverData.total}</p>

Votre page ne parle jamais à Deezer : plus de requête entre origines.

Transmettre au client

Si un script du navigateur doit réutiliser les données (tri, filtrage, bouton) :

<div id="server-data" data-payload={JSON.stringify(serverData)}></div>
const el = document.getElementById('server-data');
const parsed: unknown = JSON.parse(el?.dataset.payload ?? '{}');

if (isServerData(parsed)) {
  // ici TypeScript sait que parsed est un ServerData
}

On annote délibérément en unknown pour se forcer à vérifier : le prédicat de type du chapitre 5.1.

À vous : par où commencer

Parcours essentiel, dans l’ordre

  • 6.1 Somme différée
  • 6.2 Vérification d’un utilisateur
  • 6.5 Afficher les séismes dans la page
  • 6.6 Gérer les échecs
  • 6.8 Constater le blocage

Ensuite

  • 6.3 Les mêmes, en classes (entraînement, transfert)
  • 6.7 Service de recherche d’adresses (entraînement, transfert)
  • 6.4 Séquentiel contre parallèle (approfondissement)
  • 6.9 Une playlist Deezer côté serveur (approfondissement, transfert)