package main import ( "context" "fmt" "io" "io/fs" "net/http" "net/http/httputil" "os" "os/exec" "os/signal" "path" "path/filepath" "sync" "syscall" "time" "website/internal/config" "website/internal/log" "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) { log.Debug("running command", "command", command, "args", args) cmd = exec.CommandContext(ctx, command, args...) cmd.Env = append(os.Environ(), "DEBUG=") cmd.Cancel = func() error { log.Debug("signalling child") err := cmd.Process.Signal(os.Interrupt) if err != nil { log.Error("signal error:", "error", 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 { log.Debug("watching files under", "from", from) err := filepath.Walk(from, func(path string, info fs.FileInfo, err error) error { if err != nil { return err } // log.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() log.Debug( "build command exited", "status", 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, "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(): log.Debug("server context done") err := cmd.Process.Signal(os.Interrupt) if err != nil { return err } <-done case err := <-cmdErr: return err } } } func main() { var wg sync.WaitGroup log.Configure(false) devConfig := DevConfig{} help, err := conf.Parse("", &devConfig) if err != nil { if errors.Is(err, conf.ErrHelpWanted) { fmt.Println(help) os.Exit(1) } log.Panic("parsing dev configuration", "error", err) } log.Debug("running with in /tmp", "dir", devConfig.TempDir) ctx, cancel := context.WithCancel(context.Background()) defer cancel() log.Debug("setting interrupt handler") c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { sig := <-c log.Info("shutting down on signal", "sig", sig) cancel() sig = <-c log.Info("got second signal, dying", "sig", 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() log.Debug("waiting for first server launch") <-serverChan log.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 { log.Error(err.Error()) cancel() } done <- true }() go func() { for ready := range serverChan { if ready { log.Debug("sending reload message") eventsource.SendEventMessage("reload", "", "") } else { log.Debug("server not ready") } } }() log.Info("dev server listening on", "host", devConfig.BaseURL.String()) <-done log.Debug("dev server closed") }() fw, err := NewFileWatcher(500 * time.Millisecond) if err != nil { log.Fatal("error creating file watcher", "error", err) } err = fw.WatchAllFiles("content") if err != nil { log.Fatal("could not watch files in content directory", "error", err) } err = fw.WatchAllFiles("templates") if err != nil { log.Fatal("could not watch files in templates directory", "error", err) } var exitCode int serverErr := make(chan error, 1) loop: for { serverCtx, stopServer := context.WithCancel(ctx) log.Debug("starting build") err := build(ctx, devConfig) if err != nil { log.Error("build error:", "error", err) // don't set up the server until there's a FS change event } else { log.Debug("setting up server") wg.Add(1) go func() { defer wg.Done() serverChan <- true serverErr <- server(serverCtx, devConfig) }() } select { case <-ctx.Done(): log.Debug("main context cancelled") log.Debug("calling server shutdown") err := srv.Shutdown(devCtx) if err != nil { log.Debug("shutdown error", "error", err) } exitCode = 1 break loop case event := <-fw.Events: log.Debug("event received:", "event", event) stopServer() serverChan <- false log.Debug("waiting for server shutdown") <-serverErr log.Debug("server shutdown completed") continue case err = <-serverErr: if err != nil && err != context.Canceled { var exerr *exec.ExitError log.Error("server reported error:", "error", err) if errors.As(err, &exerr) { log.Debug("server exit error") } else { log.Debug("server other error") } break } log.Debug("no error or server context cancelled") continue } log.Debug("waiting on server") exitCode = 0 break } log.Debug("waiting for wg before shutting down") wg.Wait() os.Exit(exitCode) }