dev.go

140 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140
package frontend

import (
	"log"
	"os"
	"path/filepath"
	"time"

	"github.com/fsnotify/fsnotify"
)

// Dev starts the development server with file watching and HMR.
func (f *Frontend) Dev() error {
	if !f.DevMode {
		return nil
	}

	// Create watcher
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return err
	}

	// Watch source directory recursively
	if err := f.watchDir(watcher, f.SourceDir); err != nil {
		// If source doesn't exist, just return without watching
		if os.IsNotExist(err) {
			log.Printf("[frontend] No source directory at %s, skipping watch", f.SourceDir)
			return nil
		}
		return err
	}

	log.Printf("[frontend] Watching %s for changes", f.SourceDir)

	// Store watcher for cleanup
	f.watcher = watcher

	// Start watching in background
	go f.watchLoop(watcher)

	return nil
}

// watchDir adds a directory and all subdirectories to the watcher.
func (f *Frontend) watchDir(watcher *fsnotify.Watcher, dir string) error {
	return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return watcher.Add(path)
		}
		return nil
	})
}

// watchLoop handles file system events and triggers rebuilds.
func (f *Frontend) watchLoop(watcher *fsnotify.Watcher) {
	// Debounce timer to avoid rapid rebuilds
	var timer *time.Timer
	debounce := 100 * time.Millisecond

	rebuild := func() {
		// Guard against firing after shutdown
		select {
		case <-f.hmrDone:
			return
		default:
		}
		log.Printf("[frontend] Rebuilding components...")
		start := time.Now()

		if err := f.Build(); err != nil {
			log.Printf("[frontend] Build error: %v", err)
			return
		}

		log.Printf("[frontend] Build completed in %v", time.Since(start))
		f.notifyClients()
	}

	defer func() {
		if timer != nil {
			timer.Stop()
		}
	}()

	for {
		select {
		case event, ok := <-watcher.Events:
			if !ok {
				return
			}

			// Only rebuild on write/create/remove events
			if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Remove) {
				// Check if it's a relevant file
				ext := filepath.Ext(event.Name)
				switch ext {
				case ".tsx", ".ts", ".jsx", ".js", ".css":
					// Debounce rebuild
					if timer != nil {
						timer.Stop()
					}
					timer = time.AfterFunc(debounce, rebuild)
				}
			}

			// Watch new directories
			if event.Has(fsnotify.Create) {
				if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
					watcher.Add(event.Name)
				}
			}

		case err, ok := <-watcher.Errors:
			if !ok {
				return
			}
			log.Printf("[frontend] Watcher error: %v", err)

		case <-f.hmrDone:
			return
		}
	}
}

// Stop stops the development server and file watcher.
func (f *Frontend) Stop() {
	f.stopOnce.Do(func() {
		if f.hmrDone != nil {
			close(f.hmrDone)
		}
		if f.watcher != nil {
			f.watcher.Close()
			f.watcher = nil
		}
	})
}