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