package main import ( "context" "errors" "flag" "fmt" "log" "net/http" "os" "os/exec" "os/signal" "path/filepath" "strings" "sync" "syscall" "time" "github.com/fsnotify/fsnotify" ) // serveDist starts an HTTP file server rooted at distPath on the given address. func serveDist(ctx context.Context, distPath, addr string, wg *sync.WaitGroup) *http.Server { fs := http.FileServer(http.Dir(distPath)) srv := &http.Server{Addr: addr, Handler: fs} wg.Add(1) go func() { defer wg.Done() if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Printf("http server error: %v", err) } }() go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) }() return srv } // runMake executes `make` in the provided project directory. func runMake(projectDir string) error { cmd := exec.Command("make") cmd.Dir = projectDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr log.Printf("running make in %s", projectDir) return cmd.Run() } func shouldSkipPath(projectDir, path string) bool { rel, err := filepath.Rel(projectDir, path) if err != nil { return false } if rel == "." { return false } // Normalize to forward slashes for simple contains checks. rel = filepath.ToSlash(rel) // Skip changes inside these directories to avoid rebuild loops and noise. skipPrefixes := []string{ "_dist/", ".git/", } for _, p := range skipPrefixes { if strings.HasPrefix(rel, p) { return true } } return false } // addWatchersRecursively walks projectDir and adds fsnotify watchers to all directories except skipped ones. func addWatchersRecursively(w *fsnotify.Watcher, projectDir string) error { return filepath.WalkDir(projectDir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { return nil } if shouldSkipPath(projectDir, path) { return filepath.SkipDir } if err := w.Add(path); err != nil { return fmt.Errorf("add watcher for %s: %w", path, err) } return nil }) } func main() { log.SetFlags(log.LstdFlags | log.Lmicroseconds) // Support optional --port flag while keeping the requested positional path arg. port := flag.Int("port", 8000, "port to serve _dist on") flag.Parse() if flag.NArg() < 1 { fmt.Fprintf(os.Stderr, "usage: %s [--port PORT] \n", filepath.Base(os.Args[0])) os.Exit(2) } projectDir := flag.Arg(0) absProjectDir, err := filepath.Abs(projectDir) if err != nil { log.Fatalf("resolve project path: %v", err) } // Validate Makefile presence. if _, err := os.Stat(filepath.Join(absProjectDir, "Makefile")); err != nil { log.Fatalf("Makefile not found in %s: %v", absProjectDir, err) } distDir := filepath.Join(absProjectDir, "_dist") if err := os.MkdirAll(distDir, 0o755); err != nil { log.Fatalf("ensure _dist exists: %v", err) } // Initial build. if err := runMake(absProjectDir); err != nil { log.Printf("initial make failed: %v", err) } // Context and signal handling. ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() // Start HTTP server. var wg sync.WaitGroup addr := fmt.Sprintf(":%d", *port) _ = serveDist(ctx, distDir, addr, &wg) log.Printf("serving %s at http://localhost%s/", distDir, addr) // Set up the file watcher with debounced rebuilds. watcher, err := fsnotify.NewWatcher() if err != nil { log.Fatalf("create watcher: %v", err) } defer watcher.Close() if err := addWatchersRecursively(watcher, absProjectDir); err != nil { log.Fatalf("add watchers: %v", err) } rebuildRequests := make(chan struct{}, 1) // Watch events and coalesce rebuild requests. go func() { for { select { case <-ctx.Done(): return case ev, ok := <-watcher.Events: if !ok { return } // Track newly created directories to watch them. if (ev.Op & fsnotify.Create) == fsnotify.Create { info, err := os.Stat(ev.Name) if err == nil && info.IsDir() && !shouldSkipPath(absProjectDir, ev.Name) { _ = watcher.Add(ev.Name) } } if shouldSkipPath(absProjectDir, ev.Name) { continue } // Signal a rebuild (non-blocking). select { case rebuildRequests <- struct{}{}: default: } case err, ok := <-watcher.Errors: if !ok { return } log.Printf("watcher error: %v", err) } } }() // Debounce and run make. go func() { for { select { case <-ctx.Done(): return case <-rebuildRequests: // Debounce window. time.Sleep(200 * time.Millisecond) // Drain any queued requests. for { select { case <-rebuildRequests: default: goto START_BUILD } } START_BUILD: if err := runMake(absProjectDir); err != nil { log.Printf("make failed: %v", err) } } } }() // Block until signal then wait for server shutdown. <-ctx.Done() cancel() wg.Wait() }