// 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
}