mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-25 18:41:10 +01:00
This major overhaul of the idlewatcher system introduces a modern, real-time loading experience with Server-Sent Events (SSE) streaming and improved error handling. - **Real-time Event Streaming**: New SSE endpoint (`/$godoxy/wake-events`) provides live updates during container wake process - **Enhanced Loading Page**: Modern console-style interface with timestamped events and color-coded status messages - **Improved Static Asset Management**: Dedicated paths for CSS, JS, and favicon to avoid conflicting with upstream assets - **Event History Buffer**: Stores wake events for reconnecting clients and debugging - Refactored HTTP request handling with cleaner static asset routing - Added `WakeEvent` system with structured event types (starting, waking_dep, dep_ready, container_woke, waiting_ready, ready, error) - Implemented thread-safe event broadcasting using xsync.Map for concurrent SSE connections - Enhanced error handling with detailed logging and user-friendly error messages - Simplified loading page template system with better asset path management - Fixed race conditions in dependency waking and state management - Removed `common.go` functions (canceled, waitStarted) - moved inline for better context - Updated Waker interface to accept context parameter in Wake() method - New static asset paths use `/$godoxy/` prefix to avoid conflicts - Console-style output with Fira Code font for better readability - Color-coded event types (yellow for starting, blue for dependencies, green for success, red for errors) - Automatic page refresh when container becomes ready - Improved visual design with better glassmorphism effects and responsive layout - Real-time progress feedback during dependency wake and container startup This change transforms the static loading page into a dynamic, informative experience that keeps users informed during the wake process while maintaining backward compatibility with existing routing behavior.
193 lines
5.3 KiB
Go
193 lines
5.3 KiB
Go
package idlewatcher
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/yusing/godoxy/internal/homepage"
|
|
idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types"
|
|
gperr "github.com/yusing/goutils/errs"
|
|
httputils "github.com/yusing/goutils/http"
|
|
|
|
_ "unsafe"
|
|
)
|
|
|
|
// FIXME: html and js ccannot be separte
|
|
|
|
type ForceCacheControl struct {
|
|
expires string
|
|
http.ResponseWriter
|
|
}
|
|
|
|
func (f *ForceCacheControl) WriteHeader(code int) {
|
|
f.ResponseWriter.Header().Set("Cache-Control", "must-revalidate")
|
|
f.ResponseWriter.Header().Set("Expires", f.expires)
|
|
f.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (f *ForceCacheControl) Unwrap() http.ResponseWriter {
|
|
return f.ResponseWriter
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler.
|
|
func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
|
shouldNext := w.wakeFromHTTP(rw, r)
|
|
if !shouldNext {
|
|
return
|
|
}
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
default:
|
|
f := &ForceCacheControl{expires: w.expires().Format(http.TimeFormat), ResponseWriter: rw}
|
|
w.rp.ServeHTTP(f, r)
|
|
}
|
|
}
|
|
|
|
func (w *Watcher) handleWakeEventsSSE(rw http.ResponseWriter, r *http.Request) {
|
|
// Create a dedicated channel for this SSE connection and register it
|
|
eventCh := make(chan *WakeEvent, 10)
|
|
w.eventChs.Store(eventCh, struct{}{})
|
|
// Clean up when done
|
|
defer func() {
|
|
w.eventChs.Delete(eventCh)
|
|
close(eventCh)
|
|
}()
|
|
|
|
// Set SSE headers
|
|
rw.Header().Set("Content-Type", "text/event-stream")
|
|
rw.Header().Set("Cache-Control", "no-cache")
|
|
rw.Header().Set("Connection", "keep-alive")
|
|
rw.Header().Set("Access-Control-Allow-Origin", "*")
|
|
rw.Header().Set("Access-Control-Allow-Headers", "Cache-Control")
|
|
|
|
controller := http.NewResponseController(rw)
|
|
ctx := r.Context()
|
|
|
|
// Send historical events first
|
|
w.eventHistoryMu.RLock()
|
|
historicalEvents := make([]WakeEvent, len(w.eventHistory))
|
|
copy(historicalEvents, w.eventHistory)
|
|
w.eventHistoryMu.RUnlock()
|
|
|
|
for _, event := range historicalEvents {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
err := errors.Join(event.WriteSSE(rw), controller.Flush())
|
|
if err != nil {
|
|
gperr.LogError("Failed to write SSE event", err, &w.l)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Listen for new events and send them to client
|
|
for {
|
|
select {
|
|
case event := <-eventCh:
|
|
err := errors.Join(event.WriteSSE(rw), controller.Flush())
|
|
if err != nil {
|
|
gperr.LogError("Failed to write SSE event", err, &w.l)
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *Watcher) getFavIcon(ctx context.Context) (result homepage.FetchResult, err error) {
|
|
r := w.route
|
|
hp := r.HomepageItem()
|
|
if hp.Icon != nil {
|
|
if hp.Icon.IconSource == homepage.IconSourceRelative {
|
|
result, err = homepage.FindIcon(ctx, r, *hp.Icon.FullURL)
|
|
} else {
|
|
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon)
|
|
}
|
|
} else {
|
|
// try extract from "link[rel=icon]"
|
|
result, err = homepage.FindIcon(ctx, r, "/")
|
|
}
|
|
if result.StatusCode == 0 {
|
|
result.StatusCode = http.StatusOK
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
func serveStaticContent(rw http.ResponseWriter, status int, contentType string, content []byte) {
|
|
rw.Header().Set("Content-Type", contentType)
|
|
rw.Header().Set("Content-Length", strconv.Itoa(len(content)))
|
|
rw.WriteHeader(status)
|
|
rw.Write(content)
|
|
}
|
|
|
|
func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldNext bool) {
|
|
w.resetIdleTimer()
|
|
|
|
// handle static files
|
|
switch r.URL.Path {
|
|
case idlewatcher.FavIconPath:
|
|
result, err := w.getFavIcon(r.Context())
|
|
if err != nil {
|
|
rw.WriteHeader(result.StatusCode)
|
|
fmt.Fprint(rw, err)
|
|
return false
|
|
}
|
|
serveStaticContent(rw, result.StatusCode, result.ContentType(), result.Icon)
|
|
return false
|
|
case idlewatcher.LoadingPageCSSPath:
|
|
serveStaticContent(rw, http.StatusOK, "text/css", cssBytes)
|
|
return false
|
|
case idlewatcher.LoadingPageJSPath:
|
|
serveStaticContent(rw, http.StatusOK, "application/javascript", jsBytes)
|
|
return false
|
|
case idlewatcher.WakeEventsPath:
|
|
w.handleWakeEventsSSE(rw, r)
|
|
return false
|
|
}
|
|
|
|
// Allow request to proceed if the container is already ready.
|
|
// This check occurs after serving static files because a container can become ready quickly;
|
|
// otherwise, requests for assets may get a 404, leaving the user stuck on the loading screen.
|
|
if w.ready() {
|
|
return true
|
|
}
|
|
|
|
// Check if start endpoint is configured and request path matches
|
|
if w.cfg.StartEndpoint != "" && r.URL.Path != w.cfg.StartEndpoint {
|
|
http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden)
|
|
return false
|
|
}
|
|
|
|
accept := httputils.GetAccept(r.Header)
|
|
acceptHTML := (r.Method == http.MethodGet && accept.AcceptHTML() || r.RequestURI == "/" && accept.IsEmpty())
|
|
|
|
err := w.Wake(r.Context())
|
|
if err != nil {
|
|
gperr.LogError("Failed to wake container", err, &w.l)
|
|
if !acceptHTML {
|
|
http.Error(rw, "Failed to wake container", http.StatusInternalServerError)
|
|
return false
|
|
}
|
|
}
|
|
|
|
if !acceptHTML {
|
|
serveStaticContent(rw, http.StatusOK, "text/plain", []byte("Container woken"))
|
|
}
|
|
|
|
// Send a loading response to the client
|
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
rw.Header().Set("Cache-Control", "no-cache")
|
|
rw.Header().Add("Cache-Control", "no-store")
|
|
rw.Header().Add("Cache-Control", "must-revalidate")
|
|
rw.Header().Add("Connection", "close")
|
|
_ = w.writeLoadingPage(rw)
|
|
return false
|
|
}
|