From 2a4c795d5a165f995e9f7dc84e07465b140f3770 Mon Sep 17 00:00:00 2001 From: Alan Pearce Date: Sat, 27 Apr 2024 21:18:03 +0200 Subject: implement live-reloading dev server Squashed commit of the following: commit 02f077432202af4d633eb2cad81dfdaa6921317f Author: Alan Pearce Date: Sat Apr 27 21:09:14 2024 +0200 builder: only remove output directory if set and in dev mode commit 47001e01c55fa6e74aafeda04ebc3e4e7c47eba0 Author: Alan Pearce Date: Sat Apr 27 21:03:37 2024 +0200 implement live reload on dev server commit 411ec969f61e4b73439f1c54ea29f75135ecc618 Author: Alan Pearce Date: Sat Apr 27 20:59:26 2024 +0200 server: implement graceful shutdown commit 5400132eb6eb1b638e0b3fd4265f51611c92d473 Author: Alan Pearce Date: Sat Apr 27 20:41:07 2024 +0200 add some debug logs commit 3c9b678197c044603950232d222f501ef74d7873 Author: Alan Pearce Date: Sat Apr 27 20:39:09 2024 +0200 prefix log output with executable name commit 300e24c179e390e9d3f5aeab4471c97f17f1fa64 Author: Alan Pearce Date: Sat Apr 27 20:29:42 2024 +0200 don't panic inside internal packages, return error instead commit fe2715d330402ad67fe866471bed89c7238ad2ec Author: Alan Pearce Date: Fri Apr 26 01:18:29 2024 +0200 config: use a table to configure CSP headers commit d012553aaf78a436fa8871830b5d720a9e292d4b Author: Alan Pearce Date: Thu Apr 25 17:13:39 2024 +0200 dev: create basic dev server to build and serve from a temporary directory commit a1d11d3e69650d9b43ca1b1d7b7ccc05a808d5c1 Author: Alan Pearce Date: Thu Apr 25 13:02:22 2024 +0200 remove unused redirect_other_hostnames config option commit fd67b19b5c7f76f0c3579e8a05ef20a618e90be7 Author: Alan Pearce Date: Thu Apr 25 12:58:53 2024 +0200 server: make port a string, which is what go uses commit c798e8e736c0649008cade337158399470a9099b Author: Alan Pearce Date: Thu Apr 25 12:58:33 2024 +0200 config: remove unused port variable commit f94882b9001f3b0855e26b26b4a84b96e3deb22b Author: Alan Pearce Date: Thu Apr 25 12:49:10 2024 +0200 re-organise module layout --- cmd/dev/main.go | 313 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 cmd/dev/main.go (limited to 'cmd/dev/main.go') diff --git a/cmd/dev/main.go b/cmd/dev/main.go new file mode 100644 index 0000000..1a6ccea --- /dev/null +++ b/cmd/dev/main.go @@ -0,0 +1,313 @@ +package main + +import ( + "context" + "fmt" + "io" + "io/fs" + "log" + "log/slog" + "net/http" + "net/http/httputil" + + "os" + "os/exec" + "os/signal" + "path" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "website/internal/config" + + "github.com/antage/eventsource" + "github.com/ardanlabs/conf/v3" + "github.com/gohugoio/hugo/watcher" + + "github.com/pkg/errors" +) + +type DevConfig struct { + Source string `conf:"default:.,short:s"` + TempDir string `conf:"required,short:t"` + BaseURL config.URL `conf:"default:http://localhost:3000"` + ServerURL config.URL `conf:"default:http://localhost:3001"` +} + +func RunCommandPiped(ctx context.Context, command string, args ...string) (cmd *exec.Cmd, err error) { + slog.Debug(fmt.Sprintf("running command %s %s", command, strings.Join(args, " "))) + cmd = exec.CommandContext(ctx, command, args...) + cmd.Env = append(os.Environ(), "DEBUG=") + cmd.Cancel = func() error { + slog.Debug("signalling child") + err := cmd.Process.Signal(os.Interrupt) + if err != nil { + slog.Error(fmt.Sprintf("signal error: %v", err)) + } + return err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + stderr, err := cmd.StderrPipe() + if err != nil { + return + } + + go io.Copy(os.Stdout, stdout) + go io.Copy(os.Stderr, stderr) + + return +} + +type FileWatcher struct { + *watcher.Batcher +} + +func NewFileWatcher(pollTime time.Duration) (*FileWatcher, error) { + batcher, err := watcher.New(pollTime/5, pollTime, true) + if err != nil { + return nil, err + } + return &FileWatcher{batcher}, nil +} + +func (watcher FileWatcher) WatchAllFiles(from string) error { + slog.Debug(fmt.Sprintf("watching files under %s", from)) + err := filepath.Walk(from, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + // slog.Debug(fmt.Sprintf("adding file %s to watcher", path)) + if err = watcher.Add(path); err != nil { + return err + } + return nil + }) + return err +} + +func build(ctx context.Context, config DevConfig) error { + buildExe := filepath.Join(config.TempDir, "build") + cmd, err := RunCommandPiped(ctx, buildExe, + "--dest", path.Join(config.TempDir, "output"), + "--dev", + ) + // cmd, err := RunCommandPiped(ctx, "./devfakebuild") + + if err != nil { + return errors.WithMessage(err, "error running build command") + } + + err = cmd.Run() + slog.Debug(fmt.Sprintf("build command exited with code %d", cmd.ProcessState.ExitCode())) + if err != nil { + return errors.WithMessage(err, "error running build command") + } + return nil +} + +func server(ctx context.Context, devConfig DevConfig) error { + serverExe := path.Join(devConfig.TempDir, "server") + + cmd, err := RunCommandPiped(ctx, + serverExe, + "--port", devConfig.ServerURL.Port(), + "--root", path.Join(devConfig.TempDir, "output"), + "--in-dev-server", + ) + if err != nil { + return errors.WithMessage(err, "error running server command") + } + // cmd.Env = append(cmd.Env, "DEBUG=1") + + cmdErr := make(chan error, 1) + done := make(chan struct{}) + err = cmd.Start() + if err != nil { + return errors.WithMessage(err, fmt.Sprintf("error starting server binary")) + } + + go func() { + err := cmd.Wait() + if err == nil && cmd.ProcessState.Exited() { + err = errors.Errorf("server exited unexpectedly") + } + + cmdErr <- err + close(done) + }() + + for { + select { + case <-ctx.Done(): + slog.Debug("server context done") + cmd.Process.Signal(os.Interrupt) + <-done + case err := <-cmdErr: + return err + } + } +} + +func main() { + if os.Getenv("DEBUG") != "" { + slog.SetLogLoggerLevel(slog.LevelDebug) + } + var wg sync.WaitGroup + + devConfig := DevConfig{} + help, err := conf.Parse("", &devConfig) + if err != nil { + if errors.Is(err, conf.ErrHelpWanted) { + fmt.Println(help) + os.Exit(1) + } + log.Panicf("parsing dev configuration: %v", err) + } + + slog.Debug(fmt.Sprintf("using folder %s for build output", devConfig.TempDir)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + slog.Debug("setting interrupt handler") + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + sig := <-c + slog.Info(fmt.Sprintf("shutting down on signal %d", sig)) + cancel() + sig = <-c + slog.Info(fmt.Sprintf("got second signal, dying %d", sig)) + os.Exit(1) + }() + + serverChan := make(chan bool, 1) + eventsource := eventsource.New(nil, nil) + defer eventsource.Close() + srv := http.Server{ + Addr: devConfig.BaseURL.Host, + } + devCtx, devCancel := context.WithTimeout(ctx, 1*time.Second) + + wg.Add(1) + go func() { + defer wg.Done() + defer devCancel() + slog.Debug("waiting for first server launch") + <-serverChan + slog.Debug("got first server launch event") + + http.Handle("/", &httputil.ReverseProxy{ + Rewrite: func(req *httputil.ProxyRequest) { + req.SetURL(devConfig.ServerURL.URL) + req.Out.Host = req.In.Host + }, + }) + http.Handle("/_/reload", eventsource) + done := make(chan bool) + go func() { + err := srv.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + slog.Error(err.Error()) + cancel() + } + done <- true + }() + go func() { + for { + select { + case ready := <-serverChan: + if ready { + slog.Debug("sending reload message") + eventsource.SendEventMessage("reload", "", "") + } else { + slog.Debug("server not ready") + } + } + } + }() + slog.Info(fmt.Sprintf("dev server listening on %s", devConfig.BaseURL.Host)) + <-done + slog.Debug("dev server closed") + }() + + fw, err := NewFileWatcher(500 * time.Millisecond) + if err != nil { + log.Fatalf("error creating file watcher: %v", err) + } + err = fw.WatchAllFiles("content") + if err != nil { + log.Fatalf("could not watch files in content directory: %v", err) + } + err = fw.WatchAllFiles("templates") + if err != nil { + log.Fatalf("could not watch files in templates directory: %v", err) + } + + var exitCode int + serverErr := make(chan error, 1) +loop: + for { + serverCtx, stopServer := context.WithCancel(ctx) + slog.Debug("starting build") + + err := build(ctx, devConfig) + if err != nil { + slog.Error(fmt.Sprintf("build error: %v", err)) + // don't set up the server until there's a FS change event + } else { + slog.Debug("setting up server") + wg.Add(1) + go func() { + defer wg.Done() + serverChan <- true + serverErr <- server(serverCtx, devConfig) + }() + } + + select { + case <-ctx.Done(): + slog.Debug("main context cancelled") + slog.Debug("calling server shutdown") + srv.Shutdown(devCtx) + exitCode = 1 + break loop + case event := <-fw.Events: + slog.Debug(fmt.Sprintf("event received: %v", event)) + stopServer() + serverChan <- false + slog.Debug("waiting for server shutdown") + <-serverErr + slog.Debug("server shutdown completed") + continue + case err = <-serverErr: + if err != nil && err != context.Canceled { + var exerr *exec.ExitError + slog.Error(fmt.Sprintf("server reported error: %v", err)) + if errors.As(err, &exerr) { + slog.Debug("server exit error") + exitCode = exerr.ExitCode() + } else { + slog.Debug("server other error") + exitCode = 1 + } + break + } + slog.Debug("no error or server context cancelled") + continue + } + + slog.Debug("waiting on server") + exitCode = 0 + break + } + + slog.Debug("waiting for wg before shutting down") + wg.Wait() + os.Exit(exitCode) +} -- cgit 1.4.1