ความคืบหน้างาน ไฟล์ต้นฉบับ

tracker/live.go

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, "|")
}