package main import ( "context" "fmt" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" ) // Hub fans out change notifications to connected browsers. type Hub struct { mu sync.Mutex clients map[chan string]struct{} version int } func NewHub() *Hub { return &Hub{clients: map[chan string]struct{}{}} } func (h *Hub) subscribe() (chan string, int) { ch := make(chan string, 1) h.mu.Lock() h.clients[ch] = struct{}{} v := h.version h.mu.Unlock() return ch, v } func (h *Hub) unsubscribe(ch chan string) { h.mu.Lock() delete(h.clients, ch) h.mu.Unlock() } func (h *Hub) Broadcast() { h.mu.Lock() defer h.mu.Unlock() h.version++ msg := strconv.Itoa(h.version) for ch := range h.clients { select { case ch <- msg: default: // client is still processing the previous update; it will refetch anyway } } } // ServeHTTP streams Server-Sent Events. func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx ingress) ch, v := h.subscribe() defer h.unsubscribe(ch) fmt.Fprintf(w, "retry: 3000\nevent: hello\ndata: %d\n\n", v) flusher.Flush() heartbeat := time.NewTicker(25 * time.Second) defer heartbeat.Stop() for { select { case <-r.Context().Done(): return case msg := <-ch: fmt.Fprintf(w, "event: update\ndata: %s\n\n", msg) flusher.Flush() case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") flusher.Flush() } } } // Watch polls the data directory, reloads on change and notifies clients. func Watch(ctx context.Context, store *Store, hub *Hub, dataDir string, every time.Duration) { last := dirSignature(dataDir) t := time.NewTicker(every) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: d := dirSignature(dataDir) if d == last { continue } // Editors and os.WriteFile truncate before writing. Wait until the // files stop changing so a half-written file is not parsed as an error. last = settle(ctx, dataDir, d) store.Reload() hub.Broadcast() } } } // settle re-reads the directory signature until two reads 100 ms apart match // (at most about one second), and returns the final signature. func settle(ctx context.Context, dir, sig string) string { for range 10 { select { case <-ctx.Done(): return sig case <-time.After(100 * time.Millisecond): } next := dirSignature(dir) if next == sig { return sig } sig = next } return sig } func dirSignature(dir string) string { entries, err := os.ReadDir(dir) if err != nil { return "" } var parts []string for _, e := range entries { if e.IsDir() { if e.Name() == "diagrams" { parts = append(parts, "diagrams:"+dirSignature(filepath.Join(dir, e.Name()))) } continue } if !strings.HasSuffix(e.Name(), ".json") { continue } fi, err := os.Stat(filepath.Join(dir, e.Name())) if err != nil { continue } parts = append(parts, e.Name()+fi.ModTime().String()+strconv.FormatInt(fi.Size(), 10)) } sort.Strings(parts) return strings.Join(parts, "|") }