Add hot reload for development
All checks were successful
CI / build (pull_request) Successful in 32s

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>
This commit is contained in:
2026-01-09 17:03:28 +01:00
parent 2db0628a89
commit 62f085e8e6
9 changed files with 1244 additions and 15 deletions

View File

@@ -36,9 +36,9 @@ myapp/
The server serves files from the specified public directory. Any request path maps directly to files: The server serves files from the specified public directory. Any request path maps directly to files:
- `/` `public/index.html` - `/` -> `public/index.html`
- `/app.wasm` `public/app.wasm` - `/app.wasm` -> `public/app.wasm`
- `/styles.css` `public/styles.css` - `/styles.css` -> `public/styles.css`
**SPA Fallback**: Unknown paths and directories fall back to `index.html`, enabling client-side routing. **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. 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 ```go
func main() { func main() {
server := host.New("public", "index.html") server := host.New("public", "index.html")
http.ListenAndServe(":8080", server) log.Fatal(http.ListenAndServe(":8080", server))
} }
``` ```
Run with: Compile and deploy:
```bash
go run server.go
```
### Production
For production, compile the server and run as a binary:
```bash ```bash
go build -o server ./server.go go build -o server ./server
./server ./server
``` ```

85
host/builder.go Normal file
View File

@@ -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
}

238
host/devserver.go Normal file
View File

@@ -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)
}

80
host/inject.go Normal file
View File

@@ -0,0 +1,80 @@
package host
import (
"bytes"
"strings"
)
// ReloadScript is the JavaScript code injected into HTML pages for hot reload.
const ReloadScript = `<script>
(function() {
var wsUrl = 'ws://' + window.location.host + '/__livereload';
var ws;
var reconnectAttempts = 0;
var maxReconnectDelay = 5000;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('[iris] Hot reload connected');
reconnectAttempts = 0;
};
ws.onmessage = function(event) {
if (event.data === 'reload') {
console.log('[iris] Reloading...');
window.location.reload();
}
};
ws.onclose = function() {
var delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
reconnectAttempts++;
console.log('[iris] Connection lost. Reconnecting in ' + delay + 'ms...');
setTimeout(connect, delay);
};
ws.onerror = function() {
ws.close();
};
}
connect();
})();
</script>`
// InjectReloadScript injects the live reload script into HTML content.
// The script is inserted just before the closing </body> tag.
func InjectReloadScript(html []byte) []byte {
// Try to inject before </body>
bodyEnd := bytes.LastIndex(html, []byte("</body>"))
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 </html>
htmlEnd := bytes.LastIndex(html, []byte("</html>"))
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")
}

102
host/inject_test.go Normal file
View File

@@ -0,0 +1,102 @@
package host
import (
"strings"
"testing"
)
func TestInjectReloadScript_BeforeBody(t *testing.T) {
html := []byte(`<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>
<h1>Hello</h1>
</body>
</html>`)
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 </body>
bodyIndex := strings.Index(string(result), "</body>")
scriptIndex := strings.Index(string(result), "<script>")
if scriptIndex > bodyIndex {
t.Error("Expected script to be injected before </body>")
}
}
func TestInjectReloadScript_BeforeHTML(t *testing.T) {
// HTML without body tag
html := []byte(`<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<div>Hello</div>
</html>`)
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 </html>
htmlIndex := strings.Index(string(result), "</html>")
scriptIndex := strings.Index(string(result), "<script>")
if scriptIndex > htmlIndex {
t.Error("Expected script to be injected before </html>")
}
}
func TestInjectReloadScript_Fallback(t *testing.T) {
// Minimal HTML without body or html closing tags
html := []byte(`<div>Hello</div>`)
result := InjectReloadScript(html)
// Should contain the script
if !strings.Contains(string(result), "livereload") {
t.Error("Expected result to contain livereload script")
}
// Should be appended at the end
if !strings.HasSuffix(string(result), "</script>") {
t.Error("Expected script to be appended at the end")
}
}
func TestInjectReloadScript_ContainsReconnect(t *testing.T) {
html := []byte(`<html><body></body></html>`)
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)
}
}
}

184
host/livereload.go Normal file
View File

@@ -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
}

74
host/livereload_test.go Normal file
View File

@@ -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])
}
}

169
host/watcher.go Normal file
View File

@@ -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
}

216
host/watcher_test.go Normal file
View File

@@ -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")
}
}