All checks were successful
CI / build (push) Successful in 30s
Address review feedback to remove dead code. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
223 lines
5.5 KiB
Go
223 lines
5.5 KiB
Go
package host
|
|
|
|
import (
|
|
"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)
|
|
}
|