|
package main |
|
|
|
import ( |
|
"context" |
|
"encoding/json" |
|
"fmt" |
|
"log" |
|
"net/http" |
|
"net/http/httputil" |
|
"net/url" |
|
"os" |
|
"os/exec" |
|
"os/signal" |
|
"syscall" |
|
"time" |
|
) |
|
|
|
type ChromeVersion struct { |
|
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` |
|
} |
|
|
|
type Server struct { |
|
chromeCmd *exec.Cmd |
|
proxy *httputil.ReverseProxy |
|
wsURL *url.URL |
|
} |
|
|
|
func main() { |
|
srv := &Server{} |
|
|
|
// Start Chrome headless |
|
if err := srv.startChrome(); err != nil { |
|
log.Fatalf("Failed to start Chrome: %v", err) |
|
} |
|
defer srv.stopChrome() |
|
|
|
// Wait for Chrome to be ready and get WebSocket URL |
|
if err := srv.getWebSocketURL(); err != nil { |
|
log.Fatalf("Failed to get WebSocket URL: %v", err) |
|
} |
|
|
|
// Setup reverse proxy |
|
srv.setupProxy() |
|
|
|
// Setup HTTP handlers |
|
mux := http.NewServeMux() |
|
mux.HandleFunc("/browser", srv.handleBrowser) |
|
mux.HandleFunc("/health", srv.handleHealth) |
|
|
|
// Create HTTP server |
|
httpServer := &http.Server{ |
|
Addr: ":8080", |
|
Handler: mux, |
|
ReadTimeout: 15 * time.Second, |
|
WriteTimeout: 15 * time.Second, |
|
IdleTimeout: 60 * time.Second, |
|
} |
|
|
|
// Start server in goroutine |
|
go func() { |
|
log.Printf("Server starting on %s", httpServer.Addr) |
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
|
log.Fatalf("Server failed: %v", err) |
|
} |
|
}() |
|
|
|
// Wait for interrupt signal for graceful shutdown |
|
quit := make(chan os.Signal, 1) |
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) |
|
<-quit |
|
|
|
log.Println("Shutting down server...") |
|
|
|
// Graceful shutdown with timeout |
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
|
defer cancel() |
|
|
|
if err := httpServer.Shutdown(ctx); err != nil { |
|
log.Printf("Server forced to shutdown: %v", err) |
|
} |
|
|
|
log.Println("Server exited") |
|
} |
|
|
|
func (s *Server) startChrome() error { |
|
// Find Chrome executable |
|
chromePath := os.Getenv("CHROME_PATH") |
|
if chromePath == "" { |
|
// Common Chrome paths |
|
paths := []string{ |
|
"/usr/bin/google-chrome", |
|
"/usr/bin/chromium", |
|
"/usr/bin/chromium-browser", |
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", |
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", |
|
} |
|
for _, p := range paths { |
|
if _, err := os.Stat(p); err == nil { |
|
chromePath = p |
|
break |
|
} |
|
} |
|
} |
|
|
|
if chromePath == "" { |
|
return fmt.Errorf("Chrome executable not found. Set CHROME_PATH environment variable") |
|
} |
|
|
|
log.Printf("Using Chrome at: %s", chromePath) |
|
|
|
// Start Chrome with headless mode and remote debugging |
|
s.chromeCmd = exec.Command( |
|
chromePath, |
|
"--headless", |
|
"--disable-gpu", |
|
"--remote-debugging-port=9222", |
|
"--disable-dev-shm-usage", |
|
"--no-sandbox", |
|
"--disable-setuid-sandbox", |
|
) |
|
|
|
s.chromeCmd.Stdout = os.Stdout |
|
s.chromeCmd.Stderr = os.Stderr |
|
|
|
if err := s.chromeCmd.Start(); err != nil { |
|
return fmt.Errorf("failed to start Chrome: %w", err) |
|
} |
|
|
|
log.Printf("Chrome started with PID: %d", s.chromeCmd.Process.Pid) |
|
return nil |
|
} |
|
|
|
func (s *Server) stopChrome() { |
|
if s.chromeCmd != nil && s.chromeCmd.Process != nil { |
|
log.Println("Stopping Chrome...") |
|
if err := s.chromeCmd.Process.Kill(); err != nil { |
|
log.Printf("Error killing Chrome process: %v", err) |
|
} |
|
s.chromeCmd.Wait() |
|
} |
|
} |
|
|
|
func (s *Server) getWebSocketURL() error { |
|
// Retry logic for Chrome to be ready |
|
maxRetries := 30 |
|
for i := 0; i < maxRetries; i++ { |
|
resp, err := http.Get("http://localhost:9222/json/version") |
|
if err == nil { |
|
defer resp.Body.Close() |
|
|
|
var version ChromeVersion |
|
if err := json.NewDecoder(resp.Body).Decode(&version); err != nil { |
|
return fmt.Errorf("failed to decode version JSON: %w", err) |
|
} |
|
|
|
wsURL, err := url.Parse(version.WebSocketDebuggerURL) |
|
if err != nil { |
|
return fmt.Errorf("failed to parse WebSocket URL: %w", err) |
|
} |
|
|
|
s.wsURL = wsURL |
|
log.Printf("Chrome WebSocket URL: %s", s.wsURL.String()) |
|
return nil |
|
} |
|
|
|
log.Printf("Waiting for Chrome to be ready... (attempt %d/%d)", i+1, maxRetries) |
|
time.Sleep(500 * time.Millisecond) |
|
} |
|
|
|
return fmt.Errorf("Chrome did not become ready in time") |
|
} |
|
|
|
func (s *Server) setupProxy() { |
|
target := &url.URL{ |
|
Scheme: "http", |
|
Host: "localhost:9222", |
|
} |
|
|
|
s.proxy = httputil.NewSingleHostReverseProxy(target) |
|
|
|
// Customize director to handle WebSocket upgrades properly |
|
originalDirector := s.proxy.Director |
|
s.proxy.Director = func(req *http.Request) { |
|
originalDirector(req) |
|
// Update the path to the actual WebSocket endpoint |
|
req.URL.Path = s.wsURL.Path |
|
req.Host = target.Host |
|
} |
|
|
|
// Handle errors |
|
s.proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { |
|
log.Printf("Proxy error: %v", err) |
|
http.Error(w, "Proxy error", http.StatusBadGateway) |
|
} |
|
|
|
log.Println("Reverse proxy configured") |
|
} |
|
|
|
func (s *Server) handleBrowser(w http.ResponseWriter, r *http.Request) { |
|
log.Printf("Proxying request: %s %s", r.Method, r.URL.Path) |
|
s.proxy.ServeHTTP(w, r) |
|
} |
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { |
|
// Check if Chrome is still running |
|
if s.chromeCmd == nil || s.chromeCmd.Process == nil { |
|
w.WriteHeader(http.StatusServiceUnavailable) |
|
json.NewEncoder(w).Encode(map[string]string{ |
|
"status": "unhealthy", |
|
"reason": "Chrome process not found", |
|
}) |
|
return |
|
} |
|
|
|
// Try to reach Chrome's JSON endpoint |
|
resp, err := http.Get("http://localhost:9222/json/version") |
|
if err != nil { |
|
w.WriteHeader(http.StatusServiceUnavailable) |
|
json.NewEncoder(w).Encode(map[string]string{ |
|
"status": "unhealthy", |
|
"reason": "Chrome not responding", |
|
}) |
|
return |
|
} |
|
resp.Body.Close() |
|
|
|
w.WriteHeader(http.StatusOK) |
|
json.NewEncoder(w).Encode(map[string]string{ |
|
"status": "healthy", |
|
}) |
|
} |