v0.4.3 : anti-buffering SSE (Cloudflare) - fin des derniers messages en retard au refresh

Le chat E2E d'app.ajean.link traverse Cloudflare (ajean.link, proxy orange) qui
bufferise le flux SSE par lots : la queue du replay arrivait 20-30s apres le reste,
de facon intermittente (surtout Safari iOS). Corrige par la technique standard :
padding d'amorcage en tete de flux + padding sur caught_up (force le flush de la
queue), Cache-Control: no-transform (interdit a Cloudflare de bufferiser/compresser),
et heartbeat 15s->4s (borne le pire cas).
This commit is contained in:
nathaninline committed 2026-07-22 09:15:15 +02:00
1 parent 7d104e3213
commit 51c2c2d80f
7 files changed
+25 -8

No files matched your search

+14 -1
View File
@@ -311,6 +311,16 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
c.mu.Unlock()
}()
// 0. Amorçage anti-buffering. Le chat E2E d'app.ajean.link traverse Cloudflare
// (ajean.link, proxy orange) : tant qu'un proxy intermédiaire n'a pas reçu assez
// d'octets, il bufferise la réponse et ne la relaie qu'en retard (symptôme :
// derniers messages qui arrivent 20-30 s après le reste, de façon intermittente
// sur Safari). Envoyer d'emblée un gros événement de padding force le proxy à
// basculer en mode streaming tout de suite. Le client ignore la clé `pad`.
if !emit(map[string]any{"pad": strings.Repeat("·", 2048)}) {
return
}
// 1. Replay coalescé (snapshot hors verrou pour ne pas bloquer la génération).
c.mu.Lock()
snapshot := append([]LogEvent(nil), c.Log...)
@@ -328,7 +338,10 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
last = s
}
}
if !emit(map[string]any{"caught_up": true}) {
// Padding aussi sur caught_up : force le flush de la QUEUE du replay à travers un
// proxy qui re-bufferise par lots (Cloudflare) → les derniers messages arrivent
// avec le reste, plus 20-30 s après.
if !emit(map[string]any{"caught_up": true, "pad": strings.Repeat("·", 2048)}) {
return
}
+3 -1
View File
@@ -157,7 +157,9 @@ func handleE2EChat(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
// no-transform : interdit à Cloudflare (proxy orange sur ajean.link) de
// bufferiser/compresser le flux — sinon les événements arrivent en retard.
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("X-Accel-Buffering", "no")
flusher, _ := w.(http.Flusher)
mu, stop := sseHeartbeat(w, flusher)
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"strings"
)
const Version = "0.4.2"
const Version = "0.4.3"
func main() {
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 4,
"Patch": 2,
"Patch": 3,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 4,
"Patch": 2,
"Patch": 3,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "Jean",
"ProductVersion": "0.4.2",
"ProductVersion": "0.4.3",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {
+4 -2
View File
@@ -830,7 +830,9 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
mu := &sync.Mutex{}
done := make(chan struct{})
go func() {
t := time.NewTicker(15 * time.Second)
// 4 s (et non 15) : borne le temps qu'un dernier bout de flux peut rester
// coincé dans un buffer proxy (Cloudflare) faute d'octets pour le pousser.
t := time.NewTicker(4 * time.Second)
defer t.Stop()
for {
select {
@@ -907,7 +909,7 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("X-Accel-Buffering", "no")
flusher, _ := w.(http.Flusher)
mu, stop := sseHeartbeat(w, flusher)