1. Où apparaissent les « flux » dans l’architecture de l’app ChatGPT
Avant de débattre de ce qui est « mieux » — SSE ou HTTP-stream — il est utile de comprendre où, dans notre pile, existent des flux.
On peut, grossièrement, distinguer trois niveaux.
Premièrement, le niveau ChatGPT et du modèle. Le modèle diffuse déjà la réponse par jetons : vous voyez le texte de la réponse « s’écrire » lettre par lettre. C’est aussi un flux, mais il est entièrement piloté par OpenAI et ne concerne pas directement votre code.
Deuxièmement, le niveau MCP. Quand ChatGPT se connecte à votre serveur MCP, il maintient en général une connexion SSE : le serveur y pousse des messages MCP JSON‑RPC (réponses et notifications), et ChatGPT envoie en retour des requêtes vers un endpoint HTTP séparé, par exemple /messages. En termes MCP, c’est le transport de base.
Troisièmement, le niveau Apps SDK et votre backend. Votre widget React GiftGenius s’exécute dans le bac à sable de ChatGPT et communique avec votre backend/passerelle MCP via HTTP : au moyen d’un fetch « classique », d’un fetch avec flux (ReadableStream) ou via un abonnement SSE (EventSource).
Il est important de ne pas tout mélanger. Les événements MCP sont le « câble » entre ChatGPT et les serveurs ; tandis que SSE/HTTP-stream entre le widget et votre backend HTTP — c’est votre tronçon de route.
On peut l’illustrer par un schéma.
flowchart TD
subgraph ChatGPT
UI[ChatGPT UI + modèle]
W[GiftGenius Widget]
end
subgraph YourInfra[Infrastructure du développeur]
GW[MCP Gateway / Backend]
MCP[MCP Server]
end
UI -- "tool-call / réponses\n(flux interne de jetons)" --> W
UI <-- "MCP over SSE\n(/sse + /messages)" --> MCP
W <-- "HTTP / fetch / SSE / stream" --> GW
GW <-- "JSON-RPC MCP" --> MCP
Aujourd’hui, nous nous concentrerons sur la flèche Widget ↔ Backend et rappellerons au passage que le transport MCP lui‑même est aussi basé sur SSE.
C’est précisément sur ce tronçon — Widget ↔ Backend — que nous devons choisir comment communiquer : requêtes HTTP simples ou flux. Dans la section suivante, nous verrons pourquoi le HTTP « ordinaire » devient vite insuffisant ici.
2. Pourquoi une requête HTTP « classique » ne suffit pas
Le modèle HTTP standard est « requête → une réponse ». Le client demande quelque chose, le serveur répond une fois, la connexion est fermée.
Pour de nombreuses tâches, cela suffit : obtenir l’état courant d’un job, enregistrer les préférences d’un utilisateur, récupérer une liste de cadeaux déjà en base.
Mais dès que vous lancez une opération longue, tout commence à grincer.
Imaginez GiftGenius qui :
- agrège des signaux de plusieurs sources (historique d’achats, wishlist, réseaux sociaux),
- les passe à travers quelques requêtes LLM,
- construit un classement personnalisé à partir d’une centaine de candidats.
Tout cela peut prendre des dizaines de secondes. Si vous gardez une requête HTTP classique ouverte 40 secondes sans rien envoyer, l’UX ressemble aux vieux navigateurs : l’utilisateur regarde un spinner et se demande si l’app est morte ou « réfléchit » encore.
Au‑delà de l’UX, il y a des problèmes purement techniques :
- des timeouts côté ChatGPT, Vercel, proxys ;
- impossibilité d’envoyer la progression, des résultats partiels, etc. ;
- difficulté à gérer correctement une coupure et à se rétablir.
D’où la solution naturelle : passer d’une grande réponse unique à un flux de petits morceaux, que le serveur peut envoyer au fur et à mesure.
Ces morceaux peuvent être :
- des événements (job.progress, job.completed) — c’est le domaine du SSE ;
- des fragments d’une seule grande charge utile (texte de rapport, lignes NDJSON avec les cadeaux) — c’est le domaine du HTTP-stream.
3. SSE (Server‑Sent Events) : abonnement aux événements
Commençons par SSE, car il est en grande partie « parent » de MCP : MCP lui‑même s’appuie sur une connexion SSE au‑dessus de HTTP pour pousser des événements du serveur vers le client.
Modèle SSE en bref
SSE est un protocole au‑dessus du HTTP classique :
- le client ouvre une requête GET sur un endpoint qui répond avec Content-Type: text/event-stream ;
- le serveur ne ferme pas la connexion et écrit périodiquement des lignes du type :
event: job.progress
data: {"jobId":"123","percent":40}
event: job.completed
data: {"jobId":"123","resultCount":12}
- côté navigateur, on utilise EventSource, qui :
- gère automatiquement les tentatives de reconnexion ;
- parse le format event: + data: + double saut de ligne ;
- appelle les handlers onmessage / addEventListener("job.progress", ...).
Point clé : le canal est unidirectionnel. Seul le serveur envoie des événements au client. Le client n’envoie aucune donnée via cette connexion.
Pour les ChatGPT Apps, ce modèle convient très bien lorsque le widget veut simplement « s’abonner » aux événements par jobId et réagir à la progression et à l’achèvement de la tâche.
Exemple minimal d’endpoint SSE dans Next.js 16
Supposons que nous ayons un route handler pour les événements de progression d’un job :
app/api/gift-jobs/[jobId]/events/route.ts
import { NextRequest } from "next/server";
export async function GET(req: NextRequest, { params }: { params: { jobId: string } }) {
const jobId = params.jobId;
const stream = new ReadableStream({
start(controller) {
// Utilitaire pour envoyer un événement SSE
const send = (event: string, data: unknown) => {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(payload));
};
send("job.started", { jobId });
let percent = 0;
const interval = setInterval(() => {
percent += 20;
if (percent >= 100) {
send("job.completed", { jobId, totalGifts: 10 });
clearInterval(interval);
controller.close();
} else {
send("job.progress", { jobId, percent });
}
}, 1000);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream", // c’est ici que nous définissons le SSE
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
C’est une simulation jouet : le pourcentage augmente chaque seconde et, à la fin, on reçoit job.completed. Plus tard, vous remplacerez ce minuteur par de vrais événements du worker/de la file, mais le schéma restera le même.
Client : abonnement au SSE dans le widget GiftGenius
Dans le widget React, nous pouvons nous abonner à ce flux dès que nous avons un jobId. Je rappelle que l’API du widget s’exécute dans le bac à sable de ChatGPT, mais EventSource y est disponible comme dans un navigateur classique.
import { useEffect, useState } from "react";
export function GiftJobProgress({ jobId }: { jobId: string }) {
const [percent, setPercent] = useState(0);
useEffect(() => {
const url = `/api/gift-jobs/${jobId}/events`;
const es = new EventSource(url);
es.addEventListener("job.progress", (event) => {
const data = JSON.parse((event as MessageEvent).data);
setPercent(data.percent);
});
es.addEventListener("job.completed", () => {
setPercent(100);
es.close();
});
es.onerror = () => {
// ici, on peut afficher "Problèmes de connexion, tentative de reconnexion"
};
return () => es.close();
}, [jobId]);
return <div>Progression de la sélection des cadeaux : {percent}%</div>;
}
Vous pouvez maintenant relier cela à un outil MCP. L’outil start_gift_job renvoie un jobId, et dans le ToolOutput de votre widget vous rendez simplement GiftJobProgress.
Reconnexion automatique et Last‑Event‑ID
EventSource tente par défaut de se reconnecter automatiquement si la connexion est rompue. Le serveur peut utiliser le champ standard SSE id: dans les événements, et le client — l’en‑tête Last-Event-ID, pour rattraper les événements manqués après reconnexion.
Pour un GiftGenius simple, vous pouvez pour l’instant ne pas implémenter id: ni d’identifiant d’événement séparé, et accepter un léger « trou » de progression lors de la reconnexion. Mais en production, surtout sous forte charge, vous aurez besoin :
- d’ajouter le champ standard id: à chaque événement SSE afin que le client puisse envoyer Last-Event-ID lors de la reconnexion ;
- d’introduire un event_id applicatif dans le payload de l’événement et de vous y fier pour un traitement idempotent côté client/back‑end.
Cela se marie directement avec l’idempotence : même si un même job.progress arrive deux fois, le handler, en voyant un event_id déjà connu, n’exécutera pas deux fois les effets de bord.
Au final, SSE nous offre un abonnement pratique aux événements autour d’un jobId avec reconnexion automatique et contrôle des doublons via des identifiants d’événement. Passons maintenant au deuxième type de flux — quand on a une seule requête mais une réponse très volumineuse que l’on souhaite envoyer par morceaux.
4. HTTP‑streaming : répondre progressivement à une seule requête
Si SSE est « un abonnement à des événements indépendants », le streaming HTTP est « une requête, une réponse, mais la réponse est étalée dans le temps et arrive par chunks ».
C’est précisément le mécanisme que vous voyez quand vous utilisez l’API OpenAI avec stream : true : le serveur envoie des chunks JSON (souvent au format SSE, mais la logique reste « une requête ↔ un flux de réponse partielle »), et le client les assemble en texte final.
Dans vos API, vous pouvez faire la même chose pour :
- de longs rapports textuels (par exemple, l’explication de la logique des cadeaux choisis),
- de longues listes de cadeaux (les diffuser par morceaux au lieu de laisser l’utilisateur attendre).
Un endpoint HTTP‑stream tout simple dans Next.js
Supposons que nous devions générer une « explication » du résultat de la sélection, où le LLM écrit un long texte. Nous voulons le diffuser vers le widget au fur et à mesure de la génération.
app/api/gift-report/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode("Nous commençons l’analyse...\n"));
// Ici, on pourrait avoir une vraie génération LLM par chunks
for (const line of ["Collecte des préférences...\n", "Calcul du budget...\n", "Recommandations finales...\n"]) {
await new Promise((r) => setTimeout(r, 1000));
controller.enqueue(encoder.encode(line));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked", // Ici, nous indiquons que c’est un HTTP/stream
},
});
}
Techniquement, Next gère lui‑même l’encodage chunked ; il vous suffit de renvoyer un ReadableStream.
Lecture d’un flux HTTP dans le widget via fetch
Côté client (dans le widget), on peut lire le flux ainsi :
async function fetchReport(setText: (s: string) => void) {
const res = await fetch("/api/gift-report", { method: "POST" });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let acc = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
acc += decoder.decode(value, { stream: true });
setText(acc); // mise à jour de l'UI au fil de l'eau
}
}
Et un composant d’enrobage :
import { useState } from "react";
export function GiftReport() {
const [text, setText] = useState("");
return (
<div>
<button onClick={() => fetchReport(setText)}>Générer le rapport</button>
<pre style={{ whiteSpace: "pre-wrap" }}>{text}</pre>
</div>
);
}
C’est un schéma classique : une requête POST sur /api/gift-report, en réponse — un flux de texte que vous affichez progressivement.
Diffuser du JSON, pas du texte
Il est fréquent de vouloir diffuser non pas des chaînes, mais des objets JSON. Le format le plus populaire est le NDJSON (Newline‑delimited JSON) : chaque événement est une chaîne JSON terminée par le caractère \n.
Exemple côté serveur :
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
for (let i = 0; i < 5; i++) {
const chunk = { type: "gift", index: i, name: `Cadeau #${i}` };
controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n"));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
Le client lit avec un TextDecoder, découpe sur \n et parse les objets JSON séparés.
5. SSE vs HTTP‑stream : quelle différence et comment choisir
À ce stade, l’intuition doit déjà être là, mais fixons‑la tout de même sous la forme d’un petit tableau.
| Caractéristique | SSE (Server‑Sent Events) | HTTP‑stream (chunked) |
|---|---|---|
| Initiateur | Le client fait un GET et s’abonne | Le client fait une requête (GET/POST), le serveur diffuse la réponse |
| Direction | Serveur → client uniquement | Réponse du serveur à une requête spécifique |
| Sémantique | Abonnement à un flux d’événements (pub/sub) | Réponse partielle à une seule requête |
| Protocole intégré | Oui (event:, data:, id:, etc.) | Non, vous concevez le format vous‑même (lignes, NDJSON, JSON) |
| API côté client | EventSource | fetch + ReadableStream / response.body |
| Prise en charge de la reconnexion | Intégrée (EventSource, Last-Event-ID) | À implémenter manuellement |
| Cas typiques | Progression, statuts, notifications par jobId | Streaming de texte, de grandes réponses JSON, sortie LLM |
Pour simplifier en « règles sur les doigts » (avec modération) :
- vous avez un job et beaucoup d’événements autour → SSE ;
- un appel d’outil renvoie un résultat volumineux qu’on veut afficher progressivement → HTTP‑stream.
Pour GiftGenius, cela signifie : SSE — pour une barre de progression et des statuts en direct ; HTTP‑stream — pour un long résumé texte ou pour le chargement progressif d’une longue liste de cadeaux.
6. Comment cela s’emboîte avec MCP et GiftGenius
Rappelons notre schéma du début : modèle ↔ MCP ↔ widget ↔ backend. Nous avons déjà regardé les flux au niveau widget ↔ backend, revenons un cran en arrière et séparons précisément ce qui relève de MCP et ce qui relève du « simple HTTP ».
MCP définit comment ChatGPT (en tant que client MCP) communique avec votre serveur MCP. Pour cela, il y a un transport dans lequel :
- ChatGPT ouvre une connexion SSE /sse et y reçoit des messages MCP (réponses, notifications, événements) ;
- ChatGPT envoie des requêtes MCP (call_tool, list_tools, etc.) sur /messages, généralement en POST avec JSON‑RPC.
Vous avez déjà couvert ce niveau lors du branchement de GiftGenius à ChatGPT.
Désormais, quand nous ajoutons des tâches asynchrones et des flux UX dans le widget, deux options d’architecture apparaissent.
Première option — « MCP pur » : le serveur MCP génère lui‑même des événements job.progress et job.completed ; ChatGPT les reçoit via le SSE de MCP ; ensuite, le modèle appelle le widget avec un contexte mis à jour, et le widget rend la progression sans parler directement au backend. C’est la voie la plus « canonique » des événements MCP.
Deuxième option — hybride : l’outil MCP start_gift_job crée une tâche et renvoie un jobId ; le widget reçoit le jobId et communique ensuite lui‑même avec le backend via HTTP, en s’abonnant à l’endpoint SSE /api/gift-jobs/{jobId}/events et, si nécessaire, en demandant un flux HTTP pour le rapport. Côté MCP, rien de spécial ne se produit.
Dans ce cours, nous prenons la voie hybride : elle s’intègre mieux à App Router/Next et est plus simple pour le debug local. Vous pourrez ensuite migrer vers des « notifications MCP pures » quand vous vous sentirez à l’aise.
7. Reconnexion, timeouts et autres réalités du réseau
Jusqu’ici, tout semblait idéal : on ouvre un SSE ou un flux, tout circule, les événements arrivent, l’UX brille. Dans la vraie vie, le réseau aime couper les connexions aux moments inattendus, et l’infrastructure impose des timeouts.
Ce qui peut mal tourner
Avec SSE et les HTTP-stream, vous finirez tôt ou tard par rencontrer :
- des timeouts d’inactivité sur les proxy : « si rien ne circule pendant N secondes — on ferme » ;
- un redémarrage de votre backend (déploiement, incident) ;
- un réseau instable côté utilisateur (surtout sur mobile).
C’est normal ; l’important est d’y être préparé, pas d’espérer « que ça passe ».
Stratégie pour SSE
SSE a beaucoup d’atouts précisément dans cette zone :
- EventSource se reconnecte tout seul après un délai ;
- vous avez id: et Last-Event-ID pour rattraper les événements.
Ensemble minimal de bonnes pratiques :
- Côté serveur, envoyer périodiquement quelque chose, type heartbeat, afin que la connexion ne soit pas considérée totalement idle. Cela peut être un événement dédié event: ping ou simplement un commentaire : keep-alive.
- Côté client, dans onerror, afficher à l’utilisateur un statut compréhensible du style « Problèmes de connexion, tentative de reconnexion… » au lieu de casser tout le widget.
- Lors de la reconnexion, si vous utilisez id:, ne renvoyer depuis le serveur que les nouveaux événements après cet ID. Pour GiftGenius, vous pouvez commencer sans id: et simplement « reconstruire » l’état d’après le dernier job.progress/job.completed reçu.
Stratégie pour HTTP‑stream
Un flux HTTP correspond à une seule requête, donc en cas de coupure, il faut, en pratique, recommencer :
- si vous diffusez un rapport texte, vous pouvez simplement dire à l’utilisateur « Impossible d’obtenir le rapport complet, veuillez réessayer » et tout relancer ;
- si vous diffusez des données structurées (NDJSON), pensez à un mécanisme de resume : par exemple, passer dans la requête un offset ou un cursor à partir duquel reprendre.
Pour commencer, inutile de compliquer : si le flux de réponse est interrompu avant la fin — affichez ce qui est arrivé, et un bouton « Continuer la génération du rapport » qui enverra une nouvelle requête.
L’essentiel est de ne pas laisser l’utilisateur dans un état « d’attente éternelle ».
8. Application à GiftGenius : scénario de bout en bout
Assemblons maintenant tout ce que nous avons évoqué sur SSE, HTTP‑stream et les deux options avec MCP, sur un scénario réel GiftGenius — de la demande de l’utilisateur jusqu’au rapport final.
L’utilisateur écrit dans ChatGPT : « Trouve un cadeau pour un fan de jeux de société, budget jusqu’à 100 dollars ». Le modèle décide d’appeler GiftGenius. L’application/l’agent effectue un tool‑call start_gift_job sur votre serveur MCP. Le serveur :
- enregistre le job en base ;
- l’envoie dans une file interne (les détails des files et workers — au prochain cours, considérons pour l’instant que « quelqu’un » l’exécute) ;
- renvoie synchronement le jobId en réponse au tool‑call.
Le widget GiftGenius reçoit un ToolOutput avec le jobId et rend le composant :
function GiftGeniusRoot({ jobId }: { jobId: string }) {
return (
<div>
<h2>Nous cherchons des cadeaux parfaits...</h2>
<GiftJobProgress jobId={jobId} />
<GiftReport />
</div>
);
}
Le composant GiftJobProgress s’abonne au SSE /api/gift-jobs/{jobId}/events et affiche la progression. Chaque job.progress met à jour le pourcentage, job.completed — met 100% et, éventuellement, active un bouton « Afficher le rapport détaillé ».
Le composant GiftReport envoie au clic un POST sur /api/gift-report (en y passant le jobId) et affiche progressivement le rapport texte pendant que le serveur émet les chunks du flux HTTP.
En cas de coupure de la connexion SSE, le widget affiche un avertissement doux, et EventSource tente de se reconnecter. En cas de problème avec le flux du rapport, l’utilisateur voit une partie du rapport et un bouton « Continuer la génération » ou « Réessayer ».
Du point de vue de ChatGPT et de MCP :
- MCP voit l’appel d’outil start_gift_job et, éventuellement, ensuite des notifications sur les statuts du job ;
- l’UX autour des flux est principalement implémentée au niveau HTTP entre le widget et votre backend.
9. Erreurs typiques avec SSE et HTTP‑stream
Erreur n°1 : considérer SSE et HTTP‑stream comme « la même chose ».
Certes, en bas de pile, ils partagent HTTP et des réponses chunked, mais la sémantique est très différente. SSE — c’est un abonnement à des événements indépendants, qui peuvent arriver à tout moment, et le client ne les connaît pas à l’avance. Le flux HTTP — c’est une réponse concrète, étalée dans le temps. Si vous essayez d’implémenter un abonnement à de multiples jobId via un seul flux HTTP, vous devrez inventer vous‑même un protocole au‑dessus des octets, en recréant pratiquement la moitié de SSE.
Erreur n°2 : ignorer la reconnexion automatique de SSE et ne pas penser à l’idempotence.
Beaucoup écrivent un serveur SSE « simple » : ils envoient data: ... et n’ajoutent ni le id: standard (pour Last-Event-ID), ni un event_id applicatif dans le corps de l’événement. Puis, à la première coupure et reconnexion, les doublons d’événements se multiplient. Sans event_id bien pensé et une logique « j’ai déjà vu cet événement », le handler côté client risque de mettre l’état à jour deux fois, d’afficher deux fois le même job.completed ou, pire encore, de débiter/créditer deux fois.
Erreur n°3 : envoyer chaque micro‑événement du worker comme un événement SSE séparé.
Si vous envoyez la progression d’une tâche par SSE toutes les millisecondes, vous allez probablement saturer le réseau et le client, plutôt que réjouir l’utilisateur avec une animation fluide. Il est plus judicieux d’agréger les mises à jour et d’envoyer la progression, disons, toutes les 200–500 ms ou lors d’un changement d’étape. Le throttling et le backpressure seront traités plus tard, mais dès maintenant, réfléchissez à la fréquence des événements.
Erreur n°4 : concevoir des protocoles complexes au‑dessus du flux HTTP sans format explicite.
Anti‑pattern typique : diffuser du JSON sans séparateurs et tenter de « deviner » où finit un objet et où commence le suivant. Ou mélanger texte et JSON dans le même flux. La meilleure voie — choisir un format simple et clair : texte par lignes, ou NDJSON (un objet JSON par ligne), ou des séparateurs explicites. Ainsi, le parseur côté client restera raisonnable.
Erreur n°5 : oublier les timeouts et les flux « éternels ».
Parfois, des endpoints SSE n’envoient rien pendant 5–10 minutes, puis on s’étonne que les connexions soient coupées entre l’utilisateur et le serveur (load balancers, API gateways, proxys d’entreprise). Des événements heartbeat réguliers ou des commentaires permettent de garder la connexion vivante et de détecter à temps les coupures. Et les flux HTTP ne doivent pas devenir des réponses infinies — pour les abonnements permanents, il y a SSE.
Erreur n°6 : essayer de faire via le flux HTTP un pub/sub complexe au lieu d’événements normaux.
La tentation arrive parfois : « Faisons un seul flux, on y enverra la progression, les partial results et des logs divers ». Au final, côté client, on se retrouve avec un multiplexeur complexe qui analyse chaque chunk et décide à quel jobId il appartient. Dans la plupart des cas, il est plus simple et plus fiable d’utiliser SSE avec des événements du type job.progress, job.completed et un canal distinct par job, plutôt que d’inventer un méga‑protocole artisanal au‑dessus du flux HTTP.
Erreur n°7 : lier l’UX à l’hypothèse que le flux « ne tombe jamais ».
Tout flux finit par se couper un jour. Si, dans ce cas, votre widget reste avec une barre de progression animée à l’infini sans option d’action — l’UX est perçue comme « cassée ». Même un simple message « La connexion semble interrompue. Essayez de relancer la sélection de cadeaux » avec un bouton « Réessayer » est bien meilleur que le silence.
GO TO FULL VERSION