Files
iris/host/watcher.go
Hugo Nijhuis 62f085e8e6
All checks were successful
CI / build (pull_request) Successful in 32s
Add hot reload for development
Implement automatic rebuild and browser reload during development:

- File watcher monitors .go files for changes with configurable extensions
- Builder compiles Go source to WASM on file changes
- LiveReload WebSocket server notifies connected browsers to reload
- DevServer combines all components for easy development setup
- HTML injection adds reload script automatically

Usage:
  dev := host.NewDevServer("public", "index.html", ".", "public/app.wasm")
  dev.ListenAndServe(":8080")

Closes #9

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 17:03:28 +01:00

170 lines
3.0 KiB
Go

package host
import (
"os"
"path/filepath"
"sync"
"time"
)
// Watcher monitors files for changes and triggers callbacks.
type Watcher struct {
dir string
extensions []string
onChange func()
interval time.Duration
mu sync.Mutex
modTimes map[string]time.Time
stop chan struct{}
wg sync.WaitGroup
}
// WatcherOption configures a Watcher.
type WatcherOption func(*Watcher)
// WithExtensions sets file extensions to watch (e.g., ".go", ".html").
func WithExtensions(exts ...string) WatcherOption {
return func(w *Watcher) {
w.extensions = exts
}
}
// WithInterval sets the polling interval for file changes.
func WithInterval(d time.Duration) WatcherOption {
return func(w *Watcher) {
w.interval = d
}
}
// NewWatcher creates a new file watcher that monitors the specified directory.
func NewWatcher(dir string, onChange func(), opts ...WatcherOption) *Watcher {
w := &Watcher{
dir: dir,
extensions: []string{".go"},
onChange: onChange,
interval: 500 * time.Millisecond,
modTimes: make(map[string]time.Time),
stop: make(chan struct{}),
}
for _, opt := range opts {
opt(w)
}
return w
}
// Start begins watching for file changes.
func (w *Watcher) Start() error {
// Initial scan to populate mod times
if err := w.scan(false); err != nil {
return err
}
w.wg.Add(1)
go w.watch()
return nil
}
// Stop stops the watcher.
func (w *Watcher) Stop() {
close(w.stop)
w.wg.Wait()
}
func (w *Watcher) watch() {
defer w.wg.Done()
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-w.stop:
return
case <-ticker.C:
if err := w.scan(true); err != nil {
// Log error but continue watching
continue
}
}
}
}
func (w *Watcher) scan(notify bool) error {
w.mu.Lock()
defer w.mu.Unlock()
changed := false
seen := make(map[string]bool)
err := filepath.Walk(w.dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip files we can't access
}
if info.IsDir() {
// Skip hidden directories and vendor
name := info.Name()
if len(name) > 0 && name[0] == '.' {
return filepath.SkipDir
}
if name == "vendor" || name == "node_modules" {
return filepath.SkipDir
}
return nil
}
// Check if extension matches
ext := filepath.Ext(path)
if !w.matchesExtension(ext) {
return nil
}
seen[path] = true
modTime := info.ModTime()
if prev, exists := w.modTimes[path]; !exists {
// New file
w.modTimes[path] = modTime
if notify {
changed = true
}
} else if !modTime.Equal(prev) {
// Modified file
w.modTimes[path] = modTime
changed = true
}
return nil
})
if err != nil {
return err
}
// Check for deleted files
for path := range w.modTimes {
if !seen[path] {
delete(w.modTimes, path)
changed = true
}
}
if changed && notify && w.onChange != nil {
w.onChange()
}
return nil
}
func (w *Watcher) matchesExtension(ext string) bool {
for _, e := range w.extensions {
if e == ext {
return true
}
}
return false
}