Files
hotdog-cms/internal/serve/serve.go
T

243 lines
6.2 KiB
Go

// Package serve is the local preview: it builds the site into a private
// folder, serves it, and rebuilds and reloads the browser when a file changes.
// It is for the person writing the site, on their own machine; it is not a
// production server.
package serve
import (
"bytes"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/build"
)
// Options for the preview server.
type Options struct {
SiteDir string
Addr string // host:port; loopback by default
Drafts bool
// Endpoint, if set, is a running `hotdog-cms endpoint` that /_hotdog/ requests
// are passed to, so forms and search work in the preview as they will
// behind the real web server.
Endpoint string
// NoReload leaves the live-reload script out of pages. Its open
// connection keeps headless browsers (screenshots, tests) waiting forever.
NoReload bool
}
const reloadScript = `<script>(()=>{const s=new EventSource("/_hotdog/events");` +
`s.addEventListener("reload",()=>location.reload());` +
`s.addEventListener("error-msg",e=>console.error("hotdog-cms build failed:\n"+e.data));})();</script>`
type hub struct {
mu sync.Mutex
clients map[chan string]bool
}
func (h *hub) send(msg string) {
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
select {
case c <- msg:
default:
}
}
}
// Run serves until the process is stopped.
func Run(opt Options) error {
siteDir, err := filepath.Abs(opt.SiteDir)
if err != nil {
return err
}
ln, err := net.Listen("tcp", opt.Addr)
if err != nil {
return err
}
base := "http://" + ln.Addr().String()
tmp, err := os.MkdirTemp("", "hotdog-cms-preview-")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
out := filepath.Join(tmp, "site")
var mu sync.RWMutex
rebuild := func() error {
mu.Lock()
defer mu.Unlock()
res, err := build.Run(build.Options{SiteDir: siteDir, Out: out, Drafts: opt.Drafts, URL: base, Dev: true})
if err != nil {
return err
}
log.Printf("built %d pages in %s", res.Pages, res.Duration.Round(time.Millisecond))
return nil
}
if err := rebuild(); err != nil {
return err
}
h := &hub{clients: map[chan string]bool{}}
go watch(siteDir, func() {
if err := rebuild(); err != nil {
log.Printf("build failed: %v", err)
h.send("error-msg\n" + err.Error())
return
}
h.send("reload\n")
})
mux := http.NewServeMux()
mux.HandleFunc("/_hotdog/events", func(w http.ResponseWriter, r *http.Request) {
fl, 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-store")
c := make(chan string, 4)
h.mu.Lock()
h.clients[c] = true
h.mu.Unlock()
defer func() { h.mu.Lock(); delete(h.clients, c); h.mu.Unlock() }()
fmt.Fprint(w, ": connected\n\n")
fl.Flush()
for {
select {
case <-r.Context().Done():
return
case msg := <-c:
event, data, _ := strings.Cut(msg, "\n")
fmt.Fprintf(w, "event: %s\n", event)
for _, line := range strings.Split(data, "\n") {
fmt.Fprintf(w, "data: %s\n", line)
}
fmt.Fprint(w, "\n")
fl.Flush()
}
}
})
if opt.Endpoint != "" {
target, err := url.Parse(opt.Endpoint)
if err != nil || target.Host == "" {
return fmt.Errorf("-endpoint %q is not a URL like http://127.0.0.1:8180", opt.Endpoint)
}
proxy := httputil.NewSingleHostReverseProxy(target)
mux.Handle("/_hotdog/forms/", proxy)
mux.Handle("/_hotdog/search/", proxy)
log.Printf("passing /_hotdog/forms/ and /_hotdog/search/ to %s", opt.Endpoint)
}
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
servePreview(w, r, out, !opt.NoReload)
})
log.Printf("previewing %s at %s (Ctrl+C to stop)", siteDir, base)
return http.Serve(ln, mux)
}
// servePreview answers the way the production nginx config does: a folder
// gets its index.html, a missing path gets 404.html with a 404.
func servePreview(w http.ResponseWriter, r *http.Request, root string, reload bool) {
p := filepath.Join(root, filepath.FromSlash(filepath.Clean("/"+r.URL.Path)))
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
if !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
p = filepath.Join(p, "index.html")
}
status := http.StatusOK
data, err := os.ReadFile(p)
if err != nil {
status = http.StatusNotFound
p = filepath.Join(root, "404.html")
if data, err = os.ReadFile(p); err != nil {
http.NotFound(w, r)
return
}
}
w.Header().Set("Cache-Control", "no-store")
if strings.HasSuffix(p, ".html") {
if reload {
data = bytes.Replace(data, []byte("</body>"), []byte(reloadScript+"</body>"), 1)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(data)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
_, _ = w.Write(data)
return
}
http.ServeFile(w, r, p)
}
// watch calls fn after files under dir change, once things go quiet for a
// moment, so saving several files rebuilds once.
func watch(dir string, fn func()) {
w, err := fsnotify.NewWatcher()
if err != nil {
log.Printf("watching disabled: %v", err)
return
}
add := func(root string) {
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil || !d.IsDir() {
return nil
}
name := d.Name()
if p != dir && (strings.HasPrefix(name, ".") || name == "public" || name == "node_modules") {
return fs.SkipDir
}
_ = w.Add(p)
return nil
})
}
add(dir)
var timer *time.Timer
for {
select {
case ev, ok := <-w.Events:
if !ok {
return
}
if ev.Op&fsnotify.Create != 0 {
if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() {
add(ev.Name)
}
}
base := filepath.Base(ev.Name)
if strings.HasPrefix(base, ".") || strings.HasSuffix(base, "~") || strings.HasSuffix(base, ".swp") {
continue
}
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(150*time.Millisecond, fn)
case err, ok := <-w.Errors:
if !ok {
return
}
log.Printf("watch: %v", err)
}
}
}