package dev import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "syscall" "time" "github.com/fsnotify/fsnotify" "git.golem15.com/golem15/summercms/internal/build" "git.golem15.com/golem15/summercms/modules/bonfire" ) const defaultDebounce = 200 * time.Millisecond // Options customizes the watch loop. Tests inject Build and Start. type Options struct { Build func(ctx context.Context, appDir string, out io.Writer) error // Start runs binPath with args ("serve" followed by the forwarded // global options). Start func(ctx context.Context, binPath string, args []string, stdout, stderr io.Writer) (*exec.Cmd, error) Debounce time.Duration } // Watch builds the app, runs bin/ serve with args (the global // options summer dev was given, such as -v) and rebuilds and restarts it on // source changes. When ctx ends it stops the child with SIGTERM (serve // prints its own shutdown report), prints the dev report (rebuilds, failed // builds, restarts, uptime) and returns nil. func Watch(ctx context.Context, appDir string, out bonfire.Output, args []string) error { return watch(ctx, appDir, out, args, Options{}) } // devReport counts what a watch session did. type devReport struct { rebuilds, failed, restarts int started time.Time } func (r devReport) details() []bonfire.Detail { return []bonfire.Detail{ {Label: "Rebuilds", Value: fmt.Sprintf("%d (%d failed)", r.rebuilds+r.failed, r.failed), Warn: r.failed > 0}, {Label: "Restarts", Value: fmt.Sprint(r.restarts)}, {Label: "Uptime", Value: time.Since(r.started).Round(time.Second).String()}, } } func watch(ctx context.Context, appDir string, out bonfire.Output, args []string, opts Options) error { if appDir == "" { return fmt.Errorf("dev: app directory is empty") } abs, err := filepath.Abs(appDir) if err != nil { return fmt.Errorf("dev: resolve app directory: %w", err) } appDir = abs if out == nil { out = bonfire.NewOutput(os.Stdin, os.Stdout, os.Stderr) } if opts.Build == nil { opts.Build = build.App } if opts.Start == nil { opts.Start = startBin } if opts.Debounce <= 0 { opts.Debounce = defaultDebounce } m, err := build.LoadManifest(filepath.Join(appDir, "summer.yaml")) if err != nil { return err } binPath := filepath.Join(appDir, "bin", m.Binary) childArgs := append([]string{"serve"}, args...) // On a terminal the child gets the process's own stdout and stderr, so // serve sees a terminal and prints its styled summary and report. childOut, childErr := io.Writer(out), io.Writer(out) if bonfire.IsTerminal(out) { childOut, childErr = os.Stdout, os.Stderr } report := devReport{started: time.Now()} watcher, err := fsnotify.NewWatcher() if err != nil { return fmt.Errorf("dev: watcher: %w", err) } defer watcher.Close() if err := addWatches(watcher, appDir); err != nil { return err } var current *child defer func() { current.stop() }() out.Info(fmt.Sprintf("Watching %s; running bin/%s %s", appDir, m.Binary, strings.Join(childArgs, " "))) rebuild := func() { if ctx.Err() != nil { return } start := time.Now() if err := opts.Build(ctx, appDir, out); err != nil { report.failed++ out.Error(fmt.Sprintf("Build failed: %v", err)) return } report.rebuilds++ out.Success(fmt.Sprintf("Rebuilt in %s", time.Since(start).Round(time.Millisecond))) if ctx.Err() != nil { return } restart := current != nil current.stop() current = nil next, err := startChild(ctx, opts, binPath, childArgs, childOut, childErr) if err != nil { out.Error(fmt.Sprintf("Start failed: %v", err)) return } current = next if restart { report.restarts++ out.Info("Restarted bin/" + m.Binary) } } rebuild() events := make(chan struct{}, 1) watchErrs := make(chan error, 1) go func() { for { select { case <-ctx.Done(): return case ev, ok := <-watcher.Events: if !ok { return } if ev.Has(fsnotify.Create) { if info, err := os.Stat(ev.Name); err == nil && info.IsDir() && !ignoredPath(appDir, ev.Name) { _ = watcher.Add(ev.Name) } } if !relevant(appDir, ev) { continue } select { case events <- struct{}{}: default: } case err, ok := <-watcher.Errors: if !ok { return } if err == nil { continue } select { case watchErrs <- err: default: } } } }() var timer *time.Timer timerC := func() <-chan time.Time { if timer == nil { return nil } return timer.C } for { select { case <-ctx.Done(): current.stop() current = nil bonfire.Details(out, "Dev report", report.details()) return nil case err := <-watchErrs: out.Warning(fmt.Sprintf("Watch error: %v", err)) case <-events: if timer == nil { timer = time.NewTimer(opts.Debounce) } else { if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(opts.Debounce) } case <-timerC(): timer = nil rebuild() } } } type child struct { cmd *exec.Cmd wait <-chan error } func startChild(ctx context.Context, opts Options, binPath string, args []string, stdout, stderr io.Writer) (*child, error) { cmd, err := opts.Start(ctx, binPath, args, stdout, stderr) if err != nil { return nil, err } if cmd == nil || cmd.Process == nil { return nil, fmt.Errorf("dev: start returned no process") } ch := make(chan error, 1) go func() { ch <- cmd.Wait() }() return &child{cmd: cmd, wait: ch}, nil } func (c *child) stop() { if c == nil || c.cmd == nil || c.cmd.Process == nil { return } _ = c.cmd.Process.Signal(syscall.SIGTERM) select { case <-c.wait: case <-time.After(2 * time.Second): _ = c.cmd.Process.Kill() <-c.wait } } // startBin runs binPath with args, which watch builds as "serve" followed // by the forwarded global options. func startBin(_ context.Context, binPath string, args []string, stdout, stderr io.Writer) (*exec.Cmd, error) { cmd := exec.Command(binPath, args...) cmd.Stdout = stdout cmd.Stderr = stderr cmd.Stdin = os.Stdin if err := cmd.Start(); err != nil { return nil, err } return cmd, nil } func addWatches(watcher *fsnotify.Watcher, root string) error { return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { return nil } if ignoredPath(root, path) && path != root { return filepath.SkipDir } return watcher.Add(path) }) } func relevant(appDir string, ev fsnotify.Event) bool { if ev.Op == fsnotify.Chmod { return false } if ignoredPath(appDir, ev.Name) { return false } return watchedName(filepath.Base(ev.Name)) } func watchedName(base string) bool { switch base { case "go.mod", "go.work", ".env", "summer.yaml": return true } switch filepath.Ext(base) { case ".go", ".yaml", ".yml": return true } return false } func ignoredPath(root, path string) bool { rel, err := filepath.Rel(root, path) if err != nil { return true } if rel == "main.go" || rel == "plugins.gen.go" { return true } for _, part := range strings.Split(rel, string(os.PathSeparator)) { if part == ".git" || part == "bin" || part == "tmp" { return true } } return false }