Skip to content

Instantly share code, notes, and snippets.

@wv-jessejjohnson
Created October 6, 2025 19:11
Show Gist options
  • Select an option

  • Save wv-jessejjohnson/594b22c8bf92682afef810abbebe5f00 to your computer and use it in GitHub Desktop.

Select an option

Save wv-jessejjohnson/594b22c8bf92682afef810abbebe5f00 to your computer and use it in GitHub Desktop.
chrome-proxy: Go microservice that launches Chrome headless and proxies WebSocket connections to its DevTools interface.

chrome-proxy

Go microservice that launches Chrome headless and proxies WebSocket connections to its DevTools interface.

Build

docker build -t chrome-proxy:latest .

Run

docker run -p 8080:8080 --name chrome-proxy chrome-proxy:latest

Test

curl http://localhost:8080/health

Connect

Connect CDP client with ws://localhost:8080/browser

FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o chrome-proxy
FROM alpine:latest
RUN apk add --no-cache chromium
ENV CHROME_PATH=/usr/bin/chromium-browser
COPY --from=builder /app/chrome-proxy /chrome-proxy
EXPOSE 8080
CMD ["/chrome-proxy"]
module chrome-proxy
go 1.25.1
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",
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment