En construisant le playground web de ClaudeGate, j'avais besoin d'afficher la réponse de Claude en temps réel —
token par token, comme sur claude.ai. L'API Go expose un endpoint SSE. Côté navigateur, la première
réaction est d'utiliser EventSource, l'API native du navigateur pour consommer du Server-Sent Events.
Deux minutes plus tard, j'ai dû abandonner. EventSource ne permet pas d'envoyer des headers custom.
Pas de X-API-Key, pas d'authentification. Dead end.
La solution : fetch + ReadableStream. Même protocole SSE, mêmes événements,
mais avec un contrôle total sur les headers. Ce n'est pas forcément plus complexe à écrire,
juste moins connu. Voici comment ça fonctionne bout en bout.
Pourquoi EventSource est bloquant pour les APIs protégées
EventSource est l'API standard pour consommer du SSE. L'interface est simple :
const es = new EventSource('/api/v1/jobs/123/sse')
es.onmessage = e => console.log(e.data)
es.onerror = e => console.error('Erreur', e)
C'est propre. Reconnexion automatique incluse. Support navigateurs excellent.
Mais il y a une contrainte fondamentale : EventSource fait uniquement des requêtes
GET, sans possibilité d'ajouter des headers. L'authentification par header
(X-API-Key, Authorization: Bearer ...) est impossible.
Certains contournent ça en passant le token dans l'URL (?token=xxx).
Ce n'est pas une bonne idée : le token apparaît dans les logs serveur, l'historique du navigateur,
et potentiellement dans les en-têtes Referer.
Dès qu'il faut une authentification propre, EventSource ne suffit pas.
fetch + ReadableStream : lire le body en streaming
Avec fetch, la réponse HTTP arrive normalement — mais au lieu d'attendre que le
body soit complet, on peut le lire au fur et à mesure via response.body.getReader().
C'est là que ça devient intéressant pour du SSE.
var ctrl = new AbortController()
fetch('/api/v1/jobs/' + jobId + '/sse', {
headers: authHeaders(), // X-API-Key ou Authorization: Bearer
signal: ctrl.signal
})
.then(function(r) {
var reader = r.body.getReader() // lit le body en streaming
var decoder = new TextDecoder()
var buf = ''
function pump() {
reader.read().then(function(chunk) {
if (chunk.done) { return } // connexion fermée côté serveur
buf += decoder.decode(chunk.value, { stream: true })
var parts = buf.split('\n\n') // découpe par événement SSE
buf = parts.pop() // garde le fragment incomplet
parts.forEach(parseSSEChunk) // traite chaque événement complet
pump() // rappel récursif asynchrone
})
}
pump()
})
Quelques points clés sur ce code :
r.body.getReader() — au lieu d'attendre que r.text()
ou r.json() retournent le body complet, on obtient un lecteur de stream.
Chaque appel à reader.read() retourne une Promise qui résout dès que
des bytes arrivent, même si le stream est encore ouvert.
buf.split('\n\n') — c'est le séparateur d'événements dans
le protocole SSE. Un événement complet ressemble à event: chunk\ndata: {"text":"..."}\n\n.
On découpe sur \n\n, on traite les parties complètes, et on garde
le dernier fragment (potentiellement tronqué) dans buf pour le prochain tour.
La récursion pump() — ce n'est pas une vraie récursion au sens pile d'appels.
Chaque appel à pump() est dans un .then() asynchrone :
la fonction retourne immédiatement, la stack est libérée, et le callback est appelé plus tard
par le moteur JavaScript. Pas de stack overflow possible, même sur des streams longs.
C'est le pattern idiomatique pour lire un stream infini.
Le parseur SSE manuel
Chaque chunk extrait est un événement SSE brut. EventSource parse ce format
automatiquement. Avec fetch, c'est à nous de le faire.
Le format SSE envoyé par le serveur Go ressemble à ça :
event: chunk
data: {"text":"Bonjour"}
event: chunk
data: {"text":" monde"}
event: result
data: {"status":"completed","result":"Bonjour monde"}
Le parseur lit les lignes une par une, extrait event: et data:,
puis dispatche selon le type d'événement :
function parseSSEChunk(raw) {
// raw = "event: chunk\ndata: {\"text\":\"Bonjour\"}"
var lines = raw.split('\n')
var eventName = '', dataStr = ''
for (var k = 0; k < lines.length; k++) {
if (lines[k].startsWith('event: ')) eventName = lines[k].slice(7)
else if (lines[k].startsWith('data: ')) dataStr = lines[k].slice(6)
}
if (!dataStr) { return } // ligne vide ou commentaire SSE
var data = JSON.parse(dataStr)
if (eventName === 'chunk') {
responseBox.textContent += data.text // affiche au fur et à mesure
} else if (eventName === 'result') {
// job terminé — data contient le résultat final
console.log('Terminé :', data.result)
} else if (eventName === 'error') {
console.error('Erreur job :', data.message)
}
}
Le format SSE côté Go
Côté serveur, émettre des événements SSE en Go est direct.
La seule contrainte non-négociable : Flush() après chaque événement.
Sans ça, les données restent dans le buffer HTTP et n'arrivent pas en temps réel.
func writeSSEEvent(w http.ResponseWriter, flusher http.Flusher, eventType string, data any) {
payload, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, payload)
flusher.Flush() // envoie immédiatement sans attendre le buffer HTTP
}
func StreamSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming non supporté", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// S'abonner aux événements du job...
ch := broker.Subscribe(jobID)
defer broker.Unsubscribe(jobID, ch)
for {
select {
case event, ok := <-ch:
if !ok {
return // channel fermé = job terminé
}
writeSSEEvent(w, flusher, event.Type, event.Data)
case <-r.Context().Done():
return // client déconnecté
}
}
}
Le header Content-Type: text/event-stream indique au navigateur qu'il s'agit
d'un stream SSE. Cache-Control: no-cache évite que les proxies interceptent le stream.
AbortController — annulation propre des deux côtés
AbortController permet d'annuler la connexion fetch à tout moment.
Quand l'utilisateur ferme le modal ou clique sur "Stop", on appelle ctrl.abort() :
var ctrl = new AbortController()
// Au démarrage du stream :
fetch(url, { signal: ctrl.signal, headers: authHeaders() })
.then(/* pump() */)
.catch(function(err) {
if (err.name === 'AbortError') {
console.log('Stream annulé par l\'utilisateur')
} else {
console.error('Erreur réseau :', err)
}
})
// Pour annuler :
function stopStream() {
ctrl.abort()
}
L'annulation est propre des deux côtés. Côté navigateur, la connexion TCP est fermée immédiatement.
Côté Go, le contexte de la requête se termine : r.Context().Done() se déclenche,
le select dans StreamSSE sort de la boucle, et le defer Unsubscribe
nettoie le channel. Aucune goroutine ne reste pendue.
Le point subtil : { stream: true } dans TextDecoder
Une subtilité qui peut causer des bugs discrets avec les caractères non-ASCII :
// ✅ Correct — permet de découper les chunks UTF-8 sur plusieurs reads
decoder.decode(chunk.value, { stream: true })
// ❌ Incorrect — peut casser les caractères multi-bytes (émojis, accents) en bout de chunk
decoder.decode(chunk.value)
UTF-8 encode les caractères non-ASCII sur 2 à 4 bytes. Un chunk réseau peut se terminer
au milieu d'un caractère multi-bytes. Avec { stream: true },
le décodeur maintient un état interne et attend le prochain chunk pour compléter le caractère.
Sans cette option, les bytes partiels sont remplacés par le caractère de remplacement
Unicode (�) et le texte affiché est corrompu.
Le flux complet bout en bout
Pour résumer l'enchaînement complet dans ClaudeGate :
[User clique Send]
→ fetch POST /api/v1/jobs → CreateJob → SQLite + Queue
→ fetch GET /api/v1/jobs/{id}/sse → StreamSSE → Subscribe(id)
↕ channel Go
Worker → notify("chunk")
→ reader.read() → textContent +=
Worker → notifyAndClose("result")
→ channel fermé
→ chunk.done = true
→ pump() s'arrête
Deux requêtes fetch : une pour créer le job (POST), une pour écouter les événements (GET SSE).
Le worker Go traite le job dans une goroutine séparée et pousse les événements dans un channel.
Le handler SSE lit ce channel et les envoie au navigateur. Quand le job termine,
le channel est fermé, chunk.done passe à true, et la boucle pump() s'arrête.
Comparaison EventSource vs fetch + ReadableStream
| EventSource | fetch + ReadableStream | |
|---|---|---|
| Headers custom | ❌ | ✅ |
| Auth Bearer / API Key | ❌ | ✅ |
| Méthode HTTP | GET uniquement | GET, POST, PUT… |
| Reconnexion automatique | ✅ | ❌ (à implémenter) |
| Parsing SSE automatique | ✅ | ❌ (parseur manuel) |
| API simple | ✅ | Plus verbeux |
| Support navigateurs | Excellent | Excellent |
| Annulation explicite | es.close() | ctrl.abort() |
EventSource reste le bon choix pour des flux publics sans authentification.
Dès qu'il faut des headers — API Key, token Bearer, ou n'importe quelle forme
d'authentification par header — fetch + ReadableStream s'impose.
Le code est un peu plus long à écrire, mais la mécanique reste simple une fois
le pattern compris.
Conclusion
La frustration avec EventSource est courante chez les développeurs qui
découvrent SSE dans un contexte d'API sécurisée. L'API est bien conçue mais délibérément
contrainte — elle suit le modèle des balises <img> ou <script>,
sans headers custom. C'est une décision de spec, pas un oubli.
fetch + ReadableStream n'est pas un hack. C'est l'approche recommandée
pour du streaming HTTP authentifié. Le parseur SSE manuel représente une vingtaine de
lignes et couvre tous les cas réels. Le reste — boucle pump(),
découpage sur \n\n, AbortController — sont des patterns
standard que l'on retrouve dans tous les clients SSE modernes.
Dans ClaudeGate, ce pattern gère le streaming des réponses Claude depuis le playground web. La latence perçue est nulle : les tokens apparaissent au fur et à mesure, sans attendre la réponse complète. C'est exactement ce qu'on cherchait.