From 62f085e8e6a155eb09e969971266925f9612952f Mon Sep 17 00:00:00 2001 From: Hugo Nijhuis Date: Fri, 9 Jan 2026 17:03:28 +0100 Subject: [PATCH] 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 --- host/README.md | 111 ++++++++++++++++--- host/builder.go | 85 ++++++++++++++ host/devserver.go | 238 ++++++++++++++++++++++++++++++++++++++++ host/inject.go | 80 ++++++++++++++ host/inject_test.go | 102 +++++++++++++++++ host/livereload.go | 184 +++++++++++++++++++++++++++++++ host/livereload_test.go | 74 +++++++++++++ host/watcher.go | 169 ++++++++++++++++++++++++++++ host/watcher_test.go | 216 ++++++++++++++++++++++++++++++++++++ 9 files changed, 1244 insertions(+), 15 deletions(-) create mode 100644 host/builder.go create mode 100644 host/devserver.go create mode 100644 host/inject.go create mode 100644 host/inject_test.go create mode 100644 host/livereload.go create mode 100644 host/livereload_test.go create mode 100644 host/watcher.go create mode 100644 host/watcher_test.go diff --git a/host/README.md b/host/README.md index af9629c..526696a 100644 --- a/host/README.md +++ b/host/README.md @@ -36,9 +36,9 @@ myapp/ The server serves files from the specified public directory. Any request path maps directly to files: -- `/` → `public/index.html` -- `/app.wasm` → `public/app.wasm` -- `/styles.css` → `public/styles.css` +- `/` -> `public/index.html` +- `/app.wasm` -> `public/app.wasm` +- `/styles.css` -> `public/styles.css` **SPA Fallback**: Unknown paths and directories fall back to `index.html`, enabling client-side routing. @@ -68,28 +68,109 @@ Gzip compression is automatically applied to compressible content types when the Binary assets (PNG, JPEG, etc.) are served uncompressed since they're already compressed. -## Development vs Production +## Hot Reload (Development) -### Development +The `DevServer` provides automatic rebuild and browser reload during development: + +```go +package main + +import ( + "git.flowmade.one/flowmade-one/iris/host" +) + +func main() { + // Create dev server with hot reload + dev := host.NewDevServer( + "public", // Static files directory + "index.html", // Index file + ".", // Source directory to watch + "public/app.wasm", // WASM output path + ) + + // Start watching and serving + dev.ListenAndServe(":8080") +} +``` + +### How It Works + +1. **File Watcher**: Monitors `.go` files in the source directory for changes +2. **Auto Build**: Runs `GOOS=js GOARCH=wasm go build` when changes are detected +3. **Browser Reload**: Injects a WebSocket client into HTML pages that triggers reload on build success + +### Directory Structure for Development + +``` +myapp/ +├── main.go # WASM application entry point +├── server/ +│ └── main.go # Dev server (code above) +└── public/ + ├── index.html + ├── app.wasm # Generated by dev server + └── wasm_exec.js +``` + +### Workflow + +1. Start the dev server: `go run ./server` +2. Open `http://localhost:8080` in your browser +3. Edit your Go source files +4. Save - the browser automatically reloads with your changes + +### Custom Watch Configuration + +```go +// Watch additional file types +watcher := host.NewWatcher( + ".", + onChangeCallback, + host.WithExtensions(".go", ".html", ".css"), + host.WithInterval(100*time.Millisecond), +) +``` + +### Manual Components + +For advanced use cases, you can use the individual components: + +```go +// File watcher +watcher := host.NewWatcher(".", func() { + log.Println("Files changed") +}) +watcher.Start() +defer watcher.Stop() + +// WASM builder +builder := host.NewBuilder(".", "public/app.wasm") +result := builder.Build() +if result.Success { + log.Println("Build succeeded") +} + +// Live reload (WebSocket notifications) +lr := host.NewLiveReload() +http.Handle("/__livereload", lr) +lr.NotifyReload() // Triggers reload in all connected browsers +``` + +## Production + +For production, use the standard `Server` instead of `DevServer`: ```go func main() { server := host.New("public", "index.html") - http.ListenAndServe(":8080", server) + log.Fatal(http.ListenAndServe(":8080", server)) } ``` -Run with: -```bash -go run server.go -``` - -### Production - -For production, compile the server and run as a binary: +Compile and deploy: ```bash -go build -o server ./server.go +go build -o server ./server ./server ``` diff --git a/host/builder.go b/host/builder.go new file mode 100644 index 0000000..d913df6 --- /dev/null +++ b/host/builder.go @@ -0,0 +1,85 @@ +package host + +import ( + "bytes" + "fmt" + "os/exec" + "sync" +) + +// Builder compiles Go source to WASM. +type Builder struct { + sourceDir string + outputPath string + + mu sync.Mutex + building bool +} + +// BuildResult contains the result of a build operation. +type BuildResult struct { + Success bool + Output string + Error error +} + +// NewBuilder creates a new WASM builder. +// sourceDir is the directory containing Go source files. +// outputPath is the target WASM file path. +func NewBuilder(sourceDir, outputPath string) *Builder { + return &Builder{ + sourceDir: sourceDir, + outputPath: outputPath, + } +} + +// Build compiles the Go source to WASM. +func (b *Builder) Build() BuildResult { + b.mu.Lock() + if b.building { + b.mu.Unlock() + return BuildResult{ + Success: false, + Output: "Build already in progress", + Error: fmt.Errorf("build already in progress"), + } + } + b.building = true + b.mu.Unlock() + + defer func() { + b.mu.Lock() + b.building = false + b.mu.Unlock() + }() + + cmd := exec.Command("go", "build", "-o", b.outputPath, b.sourceDir) + cmd.Env = append(cmd.Environ(), "GOOS=js", "GOARCH=wasm") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + output := stdout.String() + stderr.String() + + if err != nil { + return BuildResult{ + Success: false, + Output: output, + Error: err, + } + } + + return BuildResult{ + Success: true, + Output: output, + } +} + +// IsBuilding returns true if a build is currently in progress. +func (b *Builder) IsBuilding() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.building +} diff --git a/host/devserver.go b/host/devserver.go new file mode 100644 index 0000000..11f843a --- /dev/null +++ b/host/devserver.go @@ -0,0 +1,238 @@ +package host + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// DevServer provides a development server with hot reload support. +// It watches source files, rebuilds WASM on changes, and notifies +// connected browsers to reload. +type DevServer struct { + publicDir string + indexFile string + sourceDir string + outputPath string + + watcher *Watcher + builder *Builder + liveReload *LiveReload + logger *log.Logger +} + +// DevServerOption configures a DevServer. +type DevServerOption func(*DevServer) + +// WithLogger sets a custom logger for the dev server. +func WithLogger(logger *log.Logger) DevServerOption { + return func(d *DevServer) { + d.logger = logger + } +} + +// NewDevServer creates a new development server with hot reload. +// +// Parameters: +// - publicDir: Directory containing static files (e.g., "public") +// - indexFile: The fallback HTML file (e.g., "index.html") +// - sourceDir: Directory containing Go source files to watch (e.g., ".") +// - outputPath: Target path for compiled WASM file (e.g., "public/app.wasm") +func NewDevServer(publicDir, indexFile, sourceDir, outputPath string, opts ...DevServerOption) *DevServer { + d := &DevServer{ + publicDir: publicDir, + indexFile: indexFile, + sourceDir: sourceDir, + outputPath: outputPath, + logger: log.New(os.Stdout, "[iris] ", log.LstdFlags), + } + + for _, opt := range opts { + opt(d) + } + + d.liveReload = NewLiveReload() + d.builder = NewBuilder(sourceDir, outputPath) + + d.watcher = NewWatcher(sourceDir, d.onFileChange, + WithExtensions(".go"), + WithInterval(500*time.Millisecond), + ) + + return d +} + +// Start begins watching for file changes. +func (d *DevServer) Start() error { + // Initial build + d.logger.Println("Performing initial build...") + result := d.builder.Build() + if !result.Success { + d.logger.Printf("Initial build failed: %v\n%s", result.Error, result.Output) + } else { + d.logger.Println("Initial build succeeded") + } + + // Start watcher + d.logger.Printf("Watching %s for changes...", d.sourceDir) + return d.watcher.Start() +} + +// Stop stops the file watcher. +func (d *DevServer) Stop() { + d.watcher.Stop() +} + +// ServeHTTP implements http.Handler interface. +func (d *DevServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Handle live reload WebSocket endpoint + if r.URL.Path == "/__livereload" { + d.liveReload.ServeHTTP(w, r) + return + } + + p := filepath.Join(d.publicDir, filepath.Clean(r.URL.Path)) + + if info, err := os.Stat(p); err != nil { + d.serveHTMLWithInjection(w, r, filepath.Join(d.publicDir, d.indexFile)) + return + } else if info.IsDir() { + d.serveHTMLWithInjection(w, r, filepath.Join(d.publicDir, d.indexFile)) + return + } + + // Check if this is an HTML file + if strings.HasSuffix(p, ".html") { + d.serveHTMLWithInjection(w, r, p) + return + } + + // Serve other files with compression + d.serveFileWithCompression(w, r, p) +} + +func (d *DevServer) onFileChange() { + d.logger.Println("File change detected, rebuilding...") + + result := d.builder.Build() + if !result.Success { + d.logger.Printf("Build failed: %v\n%s", result.Error, result.Output) + return + } + + d.logger.Println("Build succeeded, reloading browsers...") + d.liveReload.NotifyReload() +} + +func (d *DevServer) serveHTMLWithInjection(w http.ResponseWriter, r *http.Request, filePath string) { + // Read HTML file + content, err := os.ReadFile(filePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + // Inject reload script + content = InjectReloadScript(content) + + // Set headers + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + + // Check if client accepts gzip + if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Vary", "Accept-Encoding") + + gz := gzip.NewWriter(w) + defer gz.Close() + + gz.Write(content) + } else { + w.Write(content) + } +} + +func (d *DevServer) serveFileWithCompression(w http.ResponseWriter, r *http.Request, filePath string) { + // Check if client accepts gzip + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + http.ServeFile(w, r, filePath) + return + } + + // Open and read file + file, err := os.Open(filePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer file.Close() + + // Get file info for headers + fileInfo, err := file.Stat() + if err != nil { + http.Error(w, "Error reading file", http.StatusInternalServerError) + return + } + + // Set content type based on file extension + contentType := getContentType(filepath.Ext(filePath)) + w.Header().Set("Content-Type", contentType) + + // Disable caching in dev mode for WASM files + if strings.HasSuffix(filePath, ".wasm") { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + } + + // Check if we should compress this file type + if shouldCompress(contentType) { + // Set gzip headers + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Vary", "Accept-Encoding") + + // Create gzip writer + gz := gzip.NewWriter(w) + defer gz.Close() + + // Copy file content through gzip + _, err = io.Copy(gz, file) + if err != nil { + return + } + } else { + // Serve without compression + http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file) + } +} + +// ListenAndServe starts the development server on the specified address. +func (d *DevServer) ListenAndServe(addr string) error { + if err := d.Start(); err != nil { + return fmt.Errorf("failed to start watcher: %w", err) + } + + d.logger.Printf("Development server running on http://%s", addr) + return http.ListenAndServe(addr, d) +} + +// ResponseWriter wraps http.ResponseWriter to capture the response +type responseWriter struct { + http.ResponseWriter + buf *bytes.Buffer + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + return rw.buf.Write(b) +} diff --git a/host/inject.go b/host/inject.go new file mode 100644 index 0000000..c06765b --- /dev/null +++ b/host/inject.go @@ -0,0 +1,80 @@ +package host + +import ( + "bytes" + "strings" +) + +// ReloadScript is the JavaScript code injected into HTML pages for hot reload. +const ReloadScript = `` + +// InjectReloadScript injects the live reload script into HTML content. +// The script is inserted just before the closing tag. +func InjectReloadScript(html []byte) []byte { + // Try to inject before + bodyEnd := bytes.LastIndex(html, []byte("")) + if bodyEnd != -1 { + result := make([]byte, 0, len(html)+len(ReloadScript)) + result = append(result, html[:bodyEnd]...) + result = append(result, []byte(ReloadScript)...) + result = append(result, html[bodyEnd:]...) + return result + } + + // Try to inject before + htmlEnd := bytes.LastIndex(html, []byte("")) + if htmlEnd != -1 { + result := make([]byte, 0, len(html)+len(ReloadScript)) + result = append(result, html[:htmlEnd]...) + result = append(result, []byte(ReloadScript)...) + result = append(result, html[htmlEnd:]...) + return result + } + + // Append at the end as fallback + result := make([]byte, 0, len(html)+len(ReloadScript)) + result = append(result, html...) + result = append(result, []byte(ReloadScript)...) + return result +} + +// IsHTMLContent checks if the content type indicates HTML. +func IsHTMLContent(contentType string) bool { + return strings.Contains(contentType, "text/html") +} diff --git a/host/inject_test.go b/host/inject_test.go new file mode 100644 index 0000000..27418be --- /dev/null +++ b/host/inject_test.go @@ -0,0 +1,102 @@ +package host + +import ( + "strings" + "testing" +) + +func TestInjectReloadScript_BeforeBody(t *testing.T) { + html := []byte(` + +Test + +

Hello

+ +`) + + result := InjectReloadScript(html) + + // Should contain the script + if !strings.Contains(string(result), "livereload") { + t.Error("Expected result to contain livereload script") + } + + // Script should be before + bodyIndex := strings.Index(string(result), "") + scriptIndex := strings.Index(string(result), "") { + t.Error("Expected script to be appended at the end") + } +} + +func TestInjectReloadScript_ContainsReconnect(t *testing.T) { + html := []byte(``) + result := InjectReloadScript(html) + + // Should contain reconnection logic + if !strings.Contains(string(result), "reconnect") { + t.Error("Expected script to contain reconnection logic") + } +} + +func TestIsHTMLContent(t *testing.T) { + tests := []struct { + contentType string + expected bool + }{ + {"text/html", true}, + {"text/html; charset=utf-8", true}, + {"application/json", false}, + {"text/css", false}, + {"application/javascript", false}, + } + + for _, tt := range tests { + result := IsHTMLContent(tt.contentType) + if result != tt.expected { + t.Errorf("IsHTMLContent(%q) = %v, want %v", tt.contentType, result, tt.expected) + } + } +} diff --git a/host/livereload.go b/host/livereload.go new file mode 100644 index 0000000..37860f4 --- /dev/null +++ b/host/livereload.go @@ -0,0 +1,184 @@ +package host + +import ( + "bufio" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "io" + "net" + "net/http" + "sync" +) + +// LiveReload manages WebSocket connections for browser reload notifications. +type LiveReload struct { + mu sync.RWMutex + clients map[*wsConn]bool +} + +// NewLiveReload creates a new LiveReload manager. +func NewLiveReload() *LiveReload { + return &LiveReload{ + clients: make(map[*wsConn]bool), + } +} + +// ServeHTTP handles WebSocket upgrade requests. +func (lr *LiveReload) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := lr.upgrade(w, r) + if err != nil { + http.Error(w, "WebSocket upgrade failed", http.StatusBadRequest) + return + } + + lr.addClient(conn) + defer lr.removeClient(conn) + + // Keep connection alive and handle pings + conn.readLoop() +} + +// NotifyReload sends a reload message to all connected clients. +func (lr *LiveReload) NotifyReload() { + lr.mu.RLock() + defer lr.mu.RUnlock() + + for conn := range lr.clients { + conn.send([]byte("reload")) + } +} + +// ClientCount returns the number of connected clients. +func (lr *LiveReload) ClientCount() int { + lr.mu.RLock() + defer lr.mu.RUnlock() + return len(lr.clients) +} + +func (lr *LiveReload) addClient(conn *wsConn) { + lr.mu.Lock() + defer lr.mu.Unlock() + lr.clients[conn] = true +} + +func (lr *LiveReload) removeClient(conn *wsConn) { + lr.mu.Lock() + defer lr.mu.Unlock() + delete(lr.clients, conn) + conn.close() +} + +func (lr *LiveReload) upgrade(w http.ResponseWriter, r *http.Request) (*wsConn, error) { + if r.Header.Get("Upgrade") != "websocket" { + return nil, io.ErrUnexpectedEOF + } + + key := r.Header.Get("Sec-WebSocket-Key") + if key == "" { + return nil, io.ErrUnexpectedEOF + } + + // Compute accept key + h := sha1.New() + h.Write([]byte(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) + acceptKey := base64.StdEncoding.EncodeToString(h.Sum(nil)) + + // Hijack the connection + hijacker, ok := w.(http.Hijacker) + if !ok { + return nil, io.ErrUnexpectedEOF + } + + conn, bufrw, err := hijacker.Hijack() + if err != nil { + return nil, err + } + + // Send upgrade response + bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n") + bufrw.WriteString("Upgrade: websocket\r\n") + bufrw.WriteString("Connection: Upgrade\r\n") + bufrw.WriteString("Sec-WebSocket-Accept: " + acceptKey + "\r\n") + bufrw.WriteString("\r\n") + bufrw.Flush() + + return &wsConn{ + conn: conn, + rw: bufrw, + }, nil +} + +// wsConn is a minimal WebSocket connection. +type wsConn struct { + conn net.Conn + rw *bufio.ReadWriter + mu sync.Mutex + closed bool +} + +func (c *wsConn) send(data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return io.ErrClosedPipe + } + + // Write text frame + frame := makeFrame(1, data) // 1 = text frame + _, err := c.conn.Write(frame) + return err +} + +func (c *wsConn) close() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return + } + c.closed = true + + // Send close frame + frame := makeFrame(8, nil) // 8 = close frame + c.conn.Write(frame) + c.conn.Close() +} + +func (c *wsConn) readLoop() { + buf := make([]byte, 1024) + for { + _, err := c.rw.Read(buf) + if err != nil { + return + } + // We don't process incoming messages, just keep connection alive + } +} + +func makeFrame(opcode byte, data []byte) []byte { + length := len(data) + + var frame []byte + if length < 126 { + frame = make([]byte, 2+length) + frame[0] = 0x80 | opcode // FIN + opcode + frame[1] = byte(length) + copy(frame[2:], data) + } else if length < 65536 { + frame = make([]byte, 4+length) + frame[0] = 0x80 | opcode + frame[1] = 126 + binary.BigEndian.PutUint16(frame[2:], uint16(length)) + copy(frame[4:], data) + } else { + frame = make([]byte, 10+length) + frame[0] = 0x80 | opcode + frame[1] = 127 + binary.BigEndian.PutUint64(frame[2:], uint64(length)) + copy(frame[10:], data) + } + + return frame +} diff --git a/host/livereload_test.go b/host/livereload_test.go new file mode 100644 index 0000000..29ee025 --- /dev/null +++ b/host/livereload_test.go @@ -0,0 +1,74 @@ +package host + +import ( + "testing" +) + +func TestLiveReload_ClientCount(t *testing.T) { + lr := NewLiveReload() + + if count := lr.ClientCount(); count != 0 { + t.Errorf("Expected 0 clients, got %d", count) + } +} + +func TestMakeFrame_SmallPayload(t *testing.T) { + data := []byte("reload") + frame := makeFrame(1, data) + + // First byte: FIN + opcode + if frame[0] != 0x81 { + t.Errorf("Expected first byte 0x81, got 0x%02x", frame[0]) + } + + // Second byte: length (no mask, small payload) + if frame[1] != byte(len(data)) { + t.Errorf("Expected length %d, got %d", len(data), frame[1]) + } + + // Payload + payload := frame[2:] + if string(payload) != string(data) { + t.Errorf("Expected payload %q, got %q", data, payload) + } +} + +func TestMakeFrame_MediumPayload(t *testing.T) { + // Create payload between 126 and 65535 bytes + data := make([]byte, 200) + for i := range data { + data[i] = byte(i % 256) + } + + frame := makeFrame(1, data) + + // First byte: FIN + opcode + if frame[0] != 0x81 { + t.Errorf("Expected first byte 0x81, got 0x%02x", frame[0]) + } + + // Second byte: 126 indicates extended length + if frame[1] != 126 { + t.Errorf("Expected length indicator 126, got %d", frame[1]) + } + + // Extended length (2 bytes, big endian) + extLen := int(frame[2])<<8 | int(frame[3]) + if extLen != len(data) { + t.Errorf("Expected extended length %d, got %d", len(data), extLen) + } +} + +func TestMakeFrame_CloseFrame(t *testing.T) { + frame := makeFrame(8, nil) + + // First byte: FIN + close opcode + if frame[0] != 0x88 { + t.Errorf("Expected first byte 0x88, got 0x%02x", frame[0]) + } + + // Second byte: 0 length + if frame[1] != 0 { + t.Errorf("Expected length 0, got %d", frame[1]) + } +} diff --git a/host/watcher.go b/host/watcher.go new file mode 100644 index 0000000..9489422 --- /dev/null +++ b/host/watcher.go @@ -0,0 +1,169 @@ +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 +} diff --git a/host/watcher_test.go b/host/watcher_test.go new file mode 100644 index 0000000..4fa1dc1 --- /dev/null +++ b/host/watcher_test.go @@ -0,0 +1,216 @@ +package host + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestWatcher_DetectsNewFiles(t *testing.T) { + dir := t.TempDir() + + // Create initial file + initialFile := filepath.Join(dir, "initial.go") + if err := os.WriteFile(initialFile, []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount int32 + w := NewWatcher(dir, func() { + atomic.AddInt32(&changeCount, 1) + }, WithInterval(50*time.Millisecond)) + + if err := w.Start(); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Create new file + newFile := filepath.Join(dir, "new.go") + if err := os.WriteFile(newFile, []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + // Wait for detection + time.Sleep(200 * time.Millisecond) + + if count := atomic.LoadInt32(&changeCount); count == 0 { + t.Error("Expected change to be detected for new file") + } +} + +func TestWatcher_DetectsModifiedFiles(t *testing.T) { + dir := t.TempDir() + + // Create initial file + file := filepath.Join(dir, "test.go") + if err := os.WriteFile(file, []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount int32 + w := NewWatcher(dir, func() { + atomic.AddInt32(&changeCount, 1) + }, WithInterval(50*time.Millisecond)) + + if err := w.Start(); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Ensure mod time will be different + time.Sleep(100 * time.Millisecond) + + // Modify file + if err := os.WriteFile(file, []byte("package main\n// updated"), 0644); err != nil { + t.Fatal(err) + } + + // Wait for detection + time.Sleep(200 * time.Millisecond) + + if count := atomic.LoadInt32(&changeCount); count == 0 { + t.Error("Expected change to be detected for modified file") + } +} + +func TestWatcher_DetectsDeletedFiles(t *testing.T) { + dir := t.TempDir() + + // Create initial file + file := filepath.Join(dir, "test.go") + if err := os.WriteFile(file, []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount int32 + w := NewWatcher(dir, func() { + atomic.AddInt32(&changeCount, 1) + }, WithInterval(50*time.Millisecond)) + + if err := w.Start(); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Delete file + if err := os.Remove(file); err != nil { + t.Fatal(err) + } + + // Wait for detection + time.Sleep(200 * time.Millisecond) + + if count := atomic.LoadInt32(&changeCount); count == 0 { + t.Error("Expected change to be detected for deleted file") + } +} + +func TestWatcher_IgnoresNonMatchingExtensions(t *testing.T) { + dir := t.TempDir() + + // Create initial go file + goFile := filepath.Join(dir, "main.go") + if err := os.WriteFile(goFile, []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount int32 + w := NewWatcher(dir, func() { + atomic.AddInt32(&changeCount, 1) + }, WithInterval(50*time.Millisecond), WithExtensions(".go")) + + if err := w.Start(); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Create non-matching file + txtFile := filepath.Join(dir, "readme.txt") + if err := os.WriteFile(txtFile, []byte("readme"), 0644); err != nil { + t.Fatal(err) + } + + // Wait + time.Sleep(200 * time.Millisecond) + + if count := atomic.LoadInt32(&changeCount); count != 0 { + t.Error("Expected no change detected for non-matching extension") + } +} + +func TestWatcher_CustomExtensions(t *testing.T) { + dir := t.TempDir() + + // Create initial file + file := filepath.Join(dir, "style.css") + if err := os.WriteFile(file, []byte("body {}"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount int32 + w := NewWatcher(dir, func() { + atomic.AddInt32(&changeCount, 1) + }, WithInterval(50*time.Millisecond), WithExtensions(".css", ".html")) + + if err := w.Start(); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Ensure mod time will be different + time.Sleep(100 * time.Millisecond) + + // Modify css file + if err := os.WriteFile(file, []byte("body { color: red; }"), 0644); err != nil { + t.Fatal(err) + } + + // Wait for detection + time.Sleep(200 * time.Millisecond) + + if count := atomic.LoadInt32(&changeCount); count == 0 { + t.Error("Expected change to be detected for custom extension") + } +} + +func TestWatcher_SkipsHiddenDirs(t *testing.T) { + dir := t.TempDir() + + // Create hidden directory with go file + hiddenDir := filepath.Join(dir, ".hidden") + if err := os.MkdirAll(hiddenDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a visible go file first + visibleFile := filepath.Join(dir, "visible.go") + if err := os.WriteFile(visibleFile, []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + var changeCount int32 + w := NewWatcher(dir, func() { + atomic.AddInt32(&changeCount, 1) + }, WithInterval(50*time.Millisecond)) + + if err := w.Start(); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Create file in hidden directory + hiddenFile := filepath.Join(hiddenDir, "hidden.go") + if err := os.WriteFile(hiddenFile, []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + // Wait + time.Sleep(200 * time.Millisecond) + + if count := atomic.LoadInt32(&changeCount); count != 0 { + t.Error("Expected no change detected for hidden directory") + } +}