Fix SSE real-time streaming in Home Assistant Ingress mode
Add padding to overcome aiohttp 64KB buffer in HA Supervisor. Problem: - HA Supervisor uses aiohttp with 64KB StreamResponse buffer - Small SSE events (~200-500 bytes) were buffered until connection closed - Users saw all streams appear at once instead of real-time updates Solution: - Detect Ingress mode via X-Ingress-Path header - Add 64KB SSE comment padding to fill proxy buffers - Increase progress interval to 3 sec in Ingress mode (reduce traffic) - Normal mode (Docker/direct) unchanged - works exactly as before Traffic impact: - Normal mode: ~17KB per scan (unchanged) - Ingress mode: ~2-3MB per scan (acceptable for real-time updates) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -411,7 +411,14 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
|
|||||||
defer cancelProgress()
|
defer cancelProgress()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(1 * time.Second)
|
// Use longer interval for Ingress mode to reduce traffic (padding is ~64KB per event)
|
||||||
|
// Normal mode: 1 second, Ingress mode: 3 seconds
|
||||||
|
progressInterval := 1 * time.Second
|
||||||
|
if streamWriter.IsIngress() {
|
||||||
|
progressInterval = 3 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(progressInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -419,7 +426,7 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
|
|||||||
case <-progressCtx.Done():
|
case <-progressCtx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Send progress every second to prevent WriteTimeout
|
// Send progress to prevent WriteTimeout and show scanning activity
|
||||||
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
|
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
|
||||||
Tested: int(atomic.LoadInt32(&tested)),
|
Tested: int(atomic.LoadInt32(&tested)),
|
||||||
Found: int(atomic.LoadInt32(&found)),
|
Found: int(atomic.LoadInt32(&found)),
|
||||||
|
|||||||
+67
-5
@@ -5,9 +5,20 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// IngressPaddingSize is the padding size for Home Assistant Ingress mode.
|
||||||
|
// HA Supervisor uses aiohttp with 64KB buffer for StreamResponse.
|
||||||
|
// We need to fill this buffer to force immediate delivery of SSE events.
|
||||||
|
IngressPaddingSize = 64 * 1024 // 64KB
|
||||||
|
|
||||||
|
// IngressHeader is the header that Home Assistant Ingress adds to requests
|
||||||
|
IngressHeader = "X-Ingress-Path"
|
||||||
|
)
|
||||||
|
|
||||||
// Event represents a Server-Sent Event
|
// Event represents a Server-Sent Event
|
||||||
type Event struct {
|
type Event struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -253,8 +264,9 @@ func generateClientID() string {
|
|||||||
|
|
||||||
// StreamWriter provides a simple interface for writing SSE events
|
// StreamWriter provides a simple interface for writing SSE events
|
||||||
type StreamWriter struct {
|
type StreamWriter struct {
|
||||||
client *Client
|
client *Client
|
||||||
server *Server
|
server *Server
|
||||||
|
isIngress bool // True when running through Home Assistant Ingress proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStreamWriter creates a new stream writer for a client
|
// NewStreamWriter creates a new stream writer for a client
|
||||||
@@ -275,6 +287,9 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
|
|||||||
// Send initial flush to establish connection
|
// Send initial flush to establish connection
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|
||||||
|
// Detect Home Assistant Ingress mode by checking for X-Ingress-Path header
|
||||||
|
isIngress := r.Header.Get(IngressHeader) != ""
|
||||||
|
|
||||||
// Create client
|
// Create client
|
||||||
ctx, cancel := context.WithCancel(r.Context())
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
client := &Client{
|
client := &Client{
|
||||||
@@ -287,8 +302,9 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &StreamWriter{
|
return &StreamWriter{
|
||||||
client: client,
|
client: client,
|
||||||
server: s,
|
server: s,
|
||||||
|
isIngress: isIngress,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +320,48 @@ func (sw *StreamWriter) SendEvent(eventType string, data interface{}) error {
|
|||||||
return fmt.Errorf("response does not support flushing")
|
return fmt.Errorf("response does not support flushing")
|
||||||
}
|
}
|
||||||
|
|
||||||
return sw.server.writeEvent(sw.client.Response, flusher, event)
|
// Use Ingress-aware write method
|
||||||
|
return sw.writeEventWithIngress(sw.client.Response, flusher, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeEventWithIngress writes an event and adds padding for Ingress mode
|
||||||
|
func (sw *StreamWriter) writeEventWithIngress(w http.ResponseWriter, flusher http.Flusher, event Event) error {
|
||||||
|
// Write the event using standard method
|
||||||
|
if err := sw.server.writeEvent(w, flusher, event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Ingress mode, add padding to fill the 64KB buffer and force immediate delivery
|
||||||
|
if sw.isIngress {
|
||||||
|
if err := sw.writePadding(w, flusher); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePadding writes SSE comment padding to fill proxy buffers.
|
||||||
|
// SSE comments (lines starting with ':') are ignored by clients.
|
||||||
|
func (sw *StreamWriter) writePadding(w http.ResponseWriter, flusher http.Flusher) error {
|
||||||
|
// Create padding using SSE comments which are ignored by clients
|
||||||
|
// Each line is ": " + padding content + "\n"
|
||||||
|
// We need ~64KB to fill the aiohttp StreamResponse buffer
|
||||||
|
const lineSize = 1024 // 1KB per line
|
||||||
|
const numLines = 64 // 64 lines = 64KB
|
||||||
|
|
||||||
|
paddingLine := ": " + strings.Repeat(".", lineSize-4) + "\n" // -4 for ": " and "\n"
|
||||||
|
|
||||||
|
for i := 0; i < numLines; i++ {
|
||||||
|
if _, err := fmt.Fprint(w, paddingLine); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the padding
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendJSON sends JSON data as an event
|
// SendJSON sends JSON data as an event
|
||||||
@@ -312,6 +369,11 @@ func (sw *StreamWriter) SendJSON(eventType string, v interface{}) error {
|
|||||||
return sw.SendEvent(eventType, v)
|
return sw.SendEvent(eventType, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsIngress returns true if running through Home Assistant Ingress proxy
|
||||||
|
func (sw *StreamWriter) IsIngress() bool {
|
||||||
|
return sw.isIngress
|
||||||
|
}
|
||||||
|
|
||||||
// SendMessage sends a simple message
|
// SendMessage sends a simple message
|
||||||
func (sw *StreamWriter) SendMessage(message string) error {
|
func (sw *StreamWriter) SendMessage(message string) error {
|
||||||
return sw.SendEvent("message", map[string]string{"message": message})
|
return sw.SendEvent("message", map[string]string{"message": message})
|
||||||
|
|||||||
Reference in New Issue
Block a user