internal/devserver/server.go

// Package devserver hosts the output directory over HTTP with live
// reload. It is intentionally minimal: I watch the output dir for file
// changes (via the watch package) and push an SSE event to any connected
// browsers so they re-fetch. See mercemay.top/src/tilstream/ for the
// complete dev-mode pipeline.
package devserver

import (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"sync/atomic"
	"time"
)

// Config holds runtime options for the dev server.
type Config struct {
	Root        string
	Addr        string
	IndexFile   string
	ReloadPath  string
	InjectMarkup bool
}

// DefaultConfig returns the same defaults the CLI applies.
func DefaultConfig(root string) Config {
	return Config{
		Root:         root,
		Addr:         ":1313",
		IndexFile:    "index.html",
		ReloadPath:   "/__livereload",
		InjectMarkup: true,
	}
}

// Server is an http.Server plus a livereload broadcaster.
type Server struct {
	cfg     Config
	lr      *LiveReload
	srv     *http.Server
	running atomic.Bool
}

// New builds a Server. Call Run to start listening.
func New(cfg Config) *Server {
	mux := http.NewServeMux()
	lr := NewLiveReload()

	mux.Handle(cfg.ReloadPath, lr)
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		serveStatic(w, r, cfg)
	})

	return &Server{
		cfg: cfg,
		lr:  lr,
		srv: &http.Server{
			Addr:              cfg.Addr,
			Handler:           mux,
			ReadHeaderTimeout: 5 * time.Second,
		},
	}
}

// Run blocks until ctx is cancelled or the server errors.
func (s *Server) Run(ctx context.Context) error {
	s.running.Store(true)
	defer s.running.Store(false)

	errCh := make(chan error, 1)
	go func() {
		if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
			return
		}
		errCh <- nil
	}()

	select {
	case <-ctx.Done():
		shutdown, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		return s.srv.Shutdown(shutdown)
	case err := <-errCh:
		return err
	}
}

// Reload pushes a reload event to every connected browser.
func (s *Server) Reload() { s.lr.Broadcast() }

// Addr returns the address the server is bound to. Useful in tests that
// pick an ephemeral port.
func (s *Server) Addr() string { return s.srv.Addr }

func serveStatic(w http.ResponseWriter, r *http.Request, cfg Config) {
	clean := filepath.Clean(r.URL.Path)
	if clean == "/" {
		clean = "/" + cfg.IndexFile
	}
	full := filepath.Join(cfg.Root, clean)
	info, err := os.Stat(full)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			http.NotFound(w, r)
			return
		}
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if info.IsDir() {
		full = filepath.Join(full, cfg.IndexFile)
	}
	ext := strings.ToLower(filepath.Ext(full))
	if ct := MimeType(ext); ct != "" {
		w.Header().Set("Content-Type", ct)
	}
	http.ServeFile(w, r, full)
}

// IsRunning returns true while Run is active.
func (s *Server) IsRunning() bool { return s.running.Load() }

// HealthCheck is a tiny helper used by doctor to probe the server.
func HealthCheck(url string) error {
	resp, err := http.Get(url)
	if err != nil {
		return fmt.Errorf("healthcheck: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 400 {
		return fmt.Errorf("healthcheck: status %d", resp.StatusCode)
	}
	return nil
}