v1.3.6: stream reliability — server idle heartbeat + TS native reconnect

- handle.go: stream writer emits a blank-line heartbeat every 25s while
  the business sends nothing; invisible to NDJSON parsers but resets
  NAT/CDN idle timers (mobile CGNAT ~60s, Bunny ~120s)
- template_ts.go: split stream() into retry loop + streamOnce()
  - dto accepts a factory re-evaluated per attempt (cursor resume)
  - retry only on transport failures (status 4/5); opt-in via
    StreamOptions.retry, exponential backoff with jitter, abortable
  - idle watchdog (idleTimeoutMs) built on AbortSignal.timeout; no
    setTimeout in generated code (simplicity gate)
- docs: README.zh.md version table for v1.3.6
This commit is contained in:
2026-09-11 18:56:48 +08:00
parent a4993502bc
commit 94fded09d5
3 changed files with 101 additions and 9 deletions
+22 -5
View File
@@ -9,6 +9,7 @@ import (
"runtime/debug"
"strings"
"sync"
"time"
"github.com/valyala/fasthttp"
)
@@ -127,13 +128,29 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
return
}
}
for message := range streamCh {
if !writeLine(message) {
finish()
return
// 空闲心跳:业务长时间不 Send 时周期写一个空行。NDJSON 解析器
// 跳过空行(业务无感知),字节能让 NAT/CDN 的空闲计时器归零。
// 25s 需小于常见中间盒阈值(移动 CGNAT 约 60s、CDN 约 120s)。
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
for {
select {
case message, ok := <-streamCh:
if !ok {
finish()
return
}
if !writeLine(message) {
finish()
return
}
case <-heartbeat.C:
if _, err := w.WriteString("\n"); err != nil || w.Flush() != nil {
finish()
return
}
}
}
finish()
})
return
}