Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 105 additions & 5 deletions server/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -157,10 +158,12 @@ func main() {
},
scaletozero.Middleware(stz),
)
// Expose a minimal /json/version endpoint so clients that attempt to
// resolve a browser websocket URL via HTTP can succeed. We map the
// upstream path onto this proxy's host:port so clients connect back to us.
rDevtools.Get("/json/version", func(w http.ResponseWriter, r *http.Request) {
// Expose /json/version endpoint so clients that attempt to resolve a browser
// websocket URL via HTTP can succeed. We map the upstream path onto this
// proxy's host:port so clients connect back to us.
// Note: Playwright's connectOverCDP requests /json/version/ with trailing slash,
// so we register both variants to avoid 426 errors from the WebSocket handler.
jsonVersionHandler := func(w http.ResponseWriter, r *http.Request) {
current := upstreamMgr.Current()
if current == "" {
http.Error(w, "upstream not ready", http.StatusServiceUnavailable)
Expand All @@ -171,7 +174,73 @@ func main() {
_ = json.NewEncoder(w).Encode(map[string]string{
"webSocketDebuggerUrl": proxyWSURL,
})
})
}
rDevtools.Get("/json/version", jsonVersionHandler)
rDevtools.Get("/json/version/", jsonVersionHandler)

// Handler for /json and /json/list - proxies to Chrome and rewrites URLs.
// This is needed for Playwright's connectOverCDP which fetches /json for target discovery.
jsonTargetHandler := func(w http.ResponseWriter, r *http.Request) {
current := upstreamMgr.Current()
if current == "" {
http.Error(w, "upstream not ready", http.StatusServiceUnavailable)
return
}

// Parse upstream URL to get Chrome's host (e.g., ws://127.0.0.1:9223/...)
parsed, err := url.Parse(current)
if err != nil {
http.Error(w, "invalid upstream URL", http.StatusInternalServerError)
return
}

// Fetch /json from Chrome
chromeJSONURL := fmt.Sprintf("http://%s/json", parsed.Host)
resp, err := http.Get(chromeJSONURL)
if err != nil {
slogger.Error("failed to fetch /json from Chrome", "err", err, "url", chromeJSONURL)
http.Error(w, "failed to fetch target list from browser", http.StatusBadGateway)
return
}
defer resp.Body.Close()

// Verify Chrome returned a successful response
if resp.StatusCode != http.StatusOK {
slogger.Error("Chrome /json returned non-200 status", "status", resp.StatusCode, "url", chromeJSONURL)
http.Error(w, fmt.Sprintf("browser returned status %d", resp.StatusCode), http.StatusBadGateway)
return
}

// Read and parse the JSON response
var targets []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
slogger.Error("failed to decode /json response", "err", err)
http.Error(w, "failed to parse target list", http.StatusBadGateway)
return
}

// Rewrite URLs to use this proxy's host instead of Chrome's
proxyHost := r.Host
chromeHost := parsed.Host
for i := range targets {
// Rewrite webSocketDebuggerUrl
if wsURL, ok := targets[i]["webSocketDebuggerUrl"].(string); ok {
targets[i]["webSocketDebuggerUrl"] = rewriteWSURL(wsURL, chromeHost, proxyHost)
}
// Rewrite devtoolsFrontendUrl if present
if frontendURL, ok := targets[i]["devtoolsFrontendUrl"].(string); ok {
targets[i]["devtoolsFrontendUrl"] = rewriteWSURL(frontendURL, chromeHost, proxyHost)
}
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(targets)
}
rDevtools.Get("/json", jsonTargetHandler)
rDevtools.Get("/json/", jsonTargetHandler)
rDevtools.Get("/json/list", jsonTargetHandler)
rDevtools.Get("/json/list/", jsonTargetHandler)

rDevtools.Get("/*", func(w http.ResponseWriter, r *http.Request) {
devtoolsproxy.WebSocketProxyHandler(upstreamMgr, slogger, config.LogCDPMessages, stz).ServeHTTP(w, r)
})
Expand Down Expand Up @@ -227,3 +296,34 @@ func mustFFmpeg() {
panic(fmt.Errorf("ffmpeg not found or not executable: %w", err))
}
}

// rewriteWSURL replaces the Chrome host with the proxy host in WebSocket URLs.
// It handles two cases:
// 1. Direct WebSocket URLs: "ws://127.0.0.1:9223/devtools/page/..." -> "ws://127.0.0.1:9222/devtools/page/..."
// 2. DevTools frontend URLs with ws= query param: "https://...?ws=127.0.0.1:9223/..." -> "https://...?ws=127.0.0.1:9222/..."
func rewriteWSURL(urlStr, chromeHost, proxyHost string) string {
parsed, err := url.Parse(urlStr)
if err != nil {
return urlStr
}

// Case 1: Direct replacement if the URL's host matches Chrome's host
if parsed.Host == chromeHost {
parsed.Host = proxyHost
}

// Case 2: Check for ws= query parameter (used in devtoolsFrontendUrl)
// e.g., https://chrome-devtools-frontend.appspot.com/.../inspector.html?ws=127.0.0.1:9223/devtools/page/...
if wsParam := parsed.Query().Get("ws"); wsParam != "" {
// The ws param value is like "127.0.0.1:9223/devtools/page/..."
// We need to replace the host portion
if strings.HasPrefix(wsParam, chromeHost) {
newWsParam := strings.Replace(wsParam, chromeHost, proxyHost, 1)
q := parsed.Query()
q.Set("ws", newWsParam)
parsed.RawQuery = q.Encode()
}
}

return parsed.String()
}
Loading
Loading