diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 4377a86f..8c5f76df 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -261,6 +261,15 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=$CACHEIDPREFIX-ap apt -y install chromium && \ apt --no-install-recommends -y install sqlite3; +# Install ChromeDriver matching the installed Chromium version +RUN set -eux; \ + CHROMIUM_VERSION=$(chromium --version | awk '{print $2}'); \ + curl -fsSL "https://storage.googleapis.com/chrome-for-testing-public/${CHROMIUM_VERSION}/linux64/chromedriver-linux64.zip" -o /tmp/cd.zip; \ + unzip /tmp/cd.zip -d /tmp; \ + mv /tmp/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver; \ + chmod +x /usr/local/bin/chromedriver; \ + rm -rf /tmp/cd.zip /tmp/chromedriver-linux64 + # Copy Chromium policy configuration RUN mkdir -p /etc/chromium/policies/managed COPY shared/chromium-policies/managed/policy.json /etc/chromium/policies/managed/policy.json diff --git a/images/chromium-headful/run-docker.sh b/images/chromium-headful/run-docker.sh index f99c976f..aad41331 100755 --- a/images/chromium-headful/run-docker.sh +++ b/images/chromium-headful/run-docker.sh @@ -57,6 +57,7 @@ RUN_ARGS=( -v "$HOST_RECORDINGS_DIR:/recordings" --memory 8192m -p 9222:9222 + -p 9224:9224 -p 444:10001 -e DISPLAY_NUM=1 -e HEIGHT=1080 diff --git a/images/chromium-headful/run-unikernel.sh b/images/chromium-headful/run-unikernel.sh index 7a528f85..54feb23d 100755 --- a/images/chromium-headful/run-unikernel.sh +++ b/images/chromium-headful/run-unikernel.sh @@ -69,6 +69,7 @@ deploy_args=( --vcpus ${VCPUS:-4} -M 4096 -p 9222:9222/tls + -p 9224:9224/tls -p 444:10001/tls -e DISPLAY_NUM=1 -e HEIGHT=1080 diff --git a/images/chromium-headful/supervisor/services/chromedriver.conf b/images/chromium-headful/supervisor/services/chromedriver.conf new file mode 100644 index 00000000..9bca5365 --- /dev/null +++ b/images/chromium-headful/supervisor/services/chromedriver.conf @@ -0,0 +1,7 @@ +[program:chromedriver] +command=/usr/local/bin/chromedriver --port=9225 --allowed-ips=127.0.0.1 --log-level=INFO +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/chromedriver +redirect_stderr=true diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index c73e92fa..42546dd8 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -143,6 +143,7 @@ cleanup () { echo "[wrapper] Cleaning up..." # Re-enable scale-to-zero if the script terminates early enable_scale_to_zero + supervisorctl -c /etc/supervisor/supervisord.conf stop chromedriver || true supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true @@ -242,6 +243,15 @@ API_OUTPUT_DIR="${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" # Start via supervisord (env overrides are read by the service's command) supervisorctl -c /etc/supervisor/supervisord.conf start kernel-images-api +echo "[wrapper] Starting ChromeDriver via supervisord" +supervisorctl -c /etc/supervisor/supervisord.conf start chromedriver +for i in {1..50}; do + if nc -z 127.0.0.1 9225 2>/dev/null; then + break + fi + sleep 0.2 +done + echo "[wrapper] Starting PulseAudio daemon via supervisord" supervisorctl -c /etc/supervisor/supervisord.conf start pulseaudio diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index e1b16ab9..081c3660 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -153,7 +153,16 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=$CACHEIDPREFIX-ap --mount=type=cache,target=/var/lib/apt,sharing=locked,id=$CACHEIDPREFIX-apt-lib \ apt-get update -y && \ apt-get -y install chromium && \ - apt-get --no-install-recommends -y install sqlite3; + apt-get --no-install-recommends -y install sqlite3 unzip; + +# Install ChromeDriver matching the installed Chromium version +RUN set -eux; \ + CHROMIUM_VERSION=$(chromium --version | awk '{print $2}'); \ + curl -fsSL "https://storage.googleapis.com/chrome-for-testing-public/${CHROMIUM_VERSION}/linux64/chromedriver-linux64.zip" -o /tmp/cd.zip; \ + unzip /tmp/cd.zip -d /tmp; \ + mv /tmp/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver; \ + chmod +x /usr/local/bin/chromedriver; \ + rm -rf /tmp/cd.zip /tmp/chromedriver-linux64 # Copy Chromium policy configuration RUN mkdir -p /etc/chromium/policies/managed diff --git a/images/chromium-headless/image/supervisor/services/chromedriver.conf b/images/chromium-headless/image/supervisor/services/chromedriver.conf new file mode 100644 index 00000000..9bca5365 --- /dev/null +++ b/images/chromium-headless/image/supervisor/services/chromedriver.conf @@ -0,0 +1,7 @@ +[program:chromedriver] +command=/usr/local/bin/chromedriver --port=9225 --allowed-ips=127.0.0.1 --log-level=INFO +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/chromedriver +redirect_stderr=true diff --git a/images/chromium-headless/image/wrapper.sh b/images/chromium-headless/image/wrapper.sh index 9937e3cf..cb50ff96 100755 --- a/images/chromium-headless/image/wrapper.sh +++ b/images/chromium-headless/image/wrapper.sh @@ -184,6 +184,7 @@ cleanup () { echo "[wrapper] Cleaning up..." # Re-enable scale-to-zero if the script terminates early enable_scale_to_zero + supervisorctl -c /etc/supervisor/supervisord.conf stop chromedriver || true supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true supervisorctl -c /etc/supervisor/supervisord.conf stop xvfb || true supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true @@ -245,6 +246,15 @@ while ! (echo >/dev/tcp/127.0.0.1/"${API_PORT}") >/dev/null 2>&1; do sleep 0.5 done +echo "[wrapper] Starting ChromeDriver via supervisord" +supervisorctl -c /etc/supervisor/supervisord.conf start chromedriver +for i in {1..50}; do + if (echo >/dev/tcp/127.0.0.1/9225) >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + echo "[wrapper] startup complete!" # Re-enable scale-to-zero once startup has completed (when not under Docker) if [[ -z "${WITHDOCKER:-}" ]]; then diff --git a/images/chromium-headless/run-docker.sh b/images/chromium-headless/run-docker.sh index df9b88d1..56f582bf 100755 --- a/images/chromium-headless/run-docker.sh +++ b/images/chromium-headless/run-docker.sh @@ -15,6 +15,7 @@ RUN_ARGS=( --privileged --tmpfs /dev/shm:size=2g -p 9222:9222 + -p 9224:9224 -p 444:10001 -v "$HOST_RECORDINGS_DIR:/recordings" ) diff --git a/images/chromium-headless/run-unikernel.sh b/images/chromium-headless/run-unikernel.sh index 3b2b18c9..b899ba4e 100755 --- a/images/chromium-headless/run-unikernel.sh +++ b/images/chromium-headless/run-unikernel.sh @@ -20,6 +20,7 @@ deploy_args=( -e RUN_AS_ROOT="$RUN_AS_ROOT" -e LOG_CDP_MESSAGES=true -p 9222:9222/tls + -p 9224:9224/tls -p 444:10001/tls -n "$NAME" ) diff --git a/server/api b/server/api deleted file mode 100755 index 778aa1cb..00000000 Binary files a/server/api and /dev/null differ diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index 534f2a01..51a5a20f 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -390,4 +390,3 @@ func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height, re log.Info("successfully changed resolution via Neko API", "width", width, "height", height, "refresh_rate", refreshRate) return nil } - diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 7e47a0b6..f3943faf 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -22,6 +22,7 @@ import ( serverpkg "github.com/onkernel/kernel-images/server" "github.com/onkernel/kernel-images/server/cmd/api/api" "github.com/onkernel/kernel-images/server/cmd/config" + "github.com/onkernel/kernel-images/server/lib/chromedriverproxy" "github.com/onkernel/kernel-images/server/lib/devtoolsproxy" "github.com/onkernel/kernel-images/server/lib/logger" "github.com/onkernel/kernel-images/server/lib/nekoclient" @@ -158,98 +159,48 @@ func main() { }, scaletozero.Middleware(stz), ) - // 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) - return - } - proxyWSURL := (&url.URL{Scheme: "ws", Host: r.Host}).String() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "webSocketDebuggerUrl": proxyWSURL, - }) - } + // Proxy /json/version and /json/list to upstream Chrome with URL rewriting. + // Playwright's connectOverCDP requests these with trailing slashes, + // so we register both variants. + jsonVersionHandler := chromeJSONProxyHandler(upstreamMgr, slogger, "/json/version") 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) - } + jsonTargetHandler := chromeJSONProxyHandler(upstreamMgr, slogger, "/json") 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) }) srvDevtools := &http.Server{ - Addr: "0.0.0.0:9222", + Addr: fmt.Sprintf("0.0.0.0:%d", config.DevToolsProxyPort), Handler: rDevtools, } + // ChromeDriver proxy: intercepts POST /session to inject debuggerAddress, + // proxies WebSocket (BiDi) and all other HTTP to the internal ChromeDriver. + rChromeDriver := chi.NewRouter() + rChromeDriver.Use( + chiMiddleware.Logger, + chiMiddleware.Recoverer, + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctxWithLogger := logger.AddToContext(r.Context(), slogger) + next.ServeHTTP(w, r.WithContext(ctxWithLogger)) + }) + }, + scaletozero.Middleware(stz), + ) + rChromeDriver.Handle("/*", chromedriverproxy.Handler(slogger)) + + srvChromeDriver := &http.Server{ + Addr: fmt.Sprintf("0.0.0.0:%d", config.ChromeDriverProxyPort), + Handler: rChromeDriver, + } + go func() { slogger.Info("http server starting", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -266,6 +217,14 @@ func main() { } }() + go func() { + slogger.Info("chromedriver proxy starting", "addr", srvChromeDriver.Addr) + if err := srvChromeDriver.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slogger.Error("chromedriver proxy failed", "err", err) + stop() + } + }() + // graceful shutdown <-ctx.Done() slogger.Info("shutdown signal received") @@ -284,6 +243,9 @@ func main() { upstreamMgr.Stop() return srvDevtools.Shutdown(shutdownCtx) }) + g.Go(func() error { + return srvChromeDriver.Shutdown(shutdownCtx) + }) if err := g.Wait(); err != nil { slogger.Error("server failed to shutdown", "err", err) @@ -297,6 +259,68 @@ func mustFFmpeg() { } } +// chromeJSONProxyHandler returns a handler that proxies a JSON endpoint from +// Chrome's DevTools API and rewrites WebSocket/DevTools URLs to point to this proxy. +func chromeJSONProxyHandler(upstreamMgr *devtoolsproxy.UpstreamManager, slogger *slog.Logger, chromePath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + current := upstreamMgr.Current() + if current == "" { + http.Error(w, "upstream not ready", http.StatusServiceUnavailable) + return + } + + parsed, err := url.Parse(current) + if err != nil { + http.Error(w, "invalid upstream URL", http.StatusInternalServerError) + return + } + + chromeURL := fmt.Sprintf("http://%s%s", parsed.Host, chromePath) + resp, err := http.Get(chromeURL) + if err != nil { + slogger.Error("failed to fetch from Chrome", "err", err, "url", chromeURL) + http.Error(w, "failed to fetch from browser", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + slogger.Error("Chrome returned non-200 status", "status", resp.StatusCode, "url", chromeURL) + http.Error(w, fmt.Sprintf("browser returned status %d", resp.StatusCode), http.StatusBadGateway) + return + } + + var raw interface{} + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + slogger.Error("failed to decode Chrome JSON response", "err", err, "path", chromePath) + http.Error(w, "failed to parse browser response", http.StatusBadGateway) + return + } + + rewriteChromeURLs(raw, parsed.Host, r.Host) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(raw) + } +} + +var chromeURLFields = []string{"webSocketDebuggerUrl", "devtoolsFrontendUrl"} + +func rewriteChromeURLs(v interface{}, chromeHost, proxyHost string) { + switch val := v.(type) { + case map[string]interface{}: + for _, field := range chromeURLFields { + if s, ok := val[field].(string); ok { + val[field] = rewriteWSURL(s, chromeHost, proxyHost) + } + } + case []interface{}: + for _, item := range val { + rewriteChromeURLs(item, chromeHost, proxyHost) + } + } +} + // 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/..." diff --git a/server/cmd/config/config.go b/server/cmd/config/config.go index 7b063d3b..f814cbad 100644 --- a/server/cmd/config/config.go +++ b/server/cmd/config/config.go @@ -21,7 +21,11 @@ type Config struct { PathToFFmpeg string `envconfig:"FFMPEG_PATH" default:"ffmpeg"` // DevTools proxy configuration - LogCDPMessages bool `envconfig:"LOG_CDP_MESSAGES" default:"false"` + DevToolsProxyPort int `envconfig:"DEVTOOLS_PROXY_PORT" default:"9222"` + LogCDPMessages bool `envconfig:"LOG_CDP_MESSAGES" default:"false"` + + // ChromeDriver proxy: external port where the proxy listens. + ChromeDriverProxyPort int `envconfig:"CHROMEDRIVER_PROXY_PORT" default:"9224"` } // Load loads configuration from environment variables diff --git a/server/cmd/config/config_test.go b/server/cmd/config/config_test.go index 5b2ce1d2..00ff70a4 100644 --- a/server/cmd/config/config_test.go +++ b/server/cmd/config/config_test.go @@ -17,31 +17,37 @@ func TestLoad(t *testing.T) { name: "defaults (no env set)", env: map[string]string{}, wantCfg: &Config{ - Port: 10001, - FrameRate: 10, - DisplayNum: 1, - MaxSizeInMB: 500, - OutputDir: ".", - PathToFFmpeg: "ffmpeg", + Port: 10001, + FrameRate: 10, + DisplayNum: 1, + MaxSizeInMB: 500, + OutputDir: ".", + PathToFFmpeg: "ffmpeg", + DevToolsProxyPort: 9222, + ChromeDriverProxyPort: 9224, }, }, { name: "custom valid env", env: map[string]string{ - "PORT": "12345", - "FRAME_RATE": "20", - "DISPLAY_NUM": "2", - "MAX_SIZE_MB": "250", - "OUTPUT_DIR": "/tmp", - "FFMPEG_PATH": "/usr/local/bin/ffmpeg", + "PORT": "12345", + "FRAME_RATE": "20", + "DISPLAY_NUM": "2", + "MAX_SIZE_MB": "250", + "OUTPUT_DIR": "/tmp", + "FFMPEG_PATH": "/usr/local/bin/ffmpeg", + "DEVTOOLS_PROXY_PORT": "9876", + "CHROMEDRIVER_PROXY_PORT": "5432", }, wantCfg: &Config{ - Port: 12345, - FrameRate: 20, - DisplayNum: 2, - MaxSizeInMB: 250, - OutputDir: "/tmp", - PathToFFmpeg: "/usr/local/bin/ffmpeg", + Port: 12345, + FrameRate: 20, + DisplayNum: 2, + MaxSizeInMB: 250, + OutputDir: "/tmp", + PathToFFmpeg: "/usr/local/bin/ffmpeg", + DevToolsProxyPort: 9876, + ChromeDriverProxyPort: 5432, }, }, { diff --git a/server/e2e/bidi/.gitignore b/server/e2e/bidi/.gitignore new file mode 100644 index 00000000..552f2218 --- /dev/null +++ b/server/e2e/bidi/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/server/e2e/bidi/package-lock.json b/server/e2e/bidi/package-lock.json new file mode 100644 index 00000000..13796f09 --- /dev/null +++ b/server/e2e/bidi/package-lock.json @@ -0,0 +1,1078 @@ +{ + "name": "test-bidi", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-bidi", + "version": "1.0.0", + "dependencies": { + "puppeteer-core": "^24.37.3", + "selenium-webdriver": "^4.40.0", + "ws": "^8.19.0" + } + }, + "node_modules/@bazel/runfiles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", + "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", + "license": "Apache-2.0" + }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", + "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", + "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1566079", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", + "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", + "license": "BSD-3-Clause" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer-core": { + "version": "24.37.5", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", + "integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1566079", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/selenium-webdriver": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.41.0.tgz", + "integrity": "sha512-1XxuKVhr9az24xwixPBEDGSZP+P0z3ZOnCmr9Oiep0MlJN2Mk+flIjD3iBS9BgyjS4g14dikMqnrYUPIjhQBhA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/SeleniumHQ" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/selenium" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@bazel/runfiles": "^6.5.0", + "jszip": "^3.10.1", + "tmp": "^0.2.5", + "ws": "^8.19.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT", + "optional": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/server/e2e/bidi/package.json b/server/e2e/bidi/package.json new file mode 100644 index 00000000..48d068f8 --- /dev/null +++ b/server/e2e/bidi/package.json @@ -0,0 +1,11 @@ +{ + "name": "test-bidi", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "dependencies": { + "puppeteer-core": "^24.37.3", + "selenium-webdriver": "^4.40.0", + "ws": "^8.19.0" + } +} diff --git a/server/e2e/bidi/test-puppeteer-bidi.js b/server/e2e/bidi/test-puppeteer-bidi.js new file mode 100644 index 00000000..e89396eb --- /dev/null +++ b/server/e2e/bidi/test-puppeteer-bidi.js @@ -0,0 +1,60 @@ +const puppeteer = require('puppeteer-core'); + +const endpoint = getArg('--endpoint', 'ws://localhost:9224/session'); + +async function main() { + const browser = await puppeteer.connect({ + browserWSEndpoint: endpoint, + protocol: 'webDriverBiDi', + capabilities: { + alwaysMatch: { + unhandledPromptBehavior: { default: 'ignore' }, + }, + }, + }); + + const pages = await browser.pages(); + const page = pages[0]; + console.log('Connected, pages:', pages.length); + + // 1. Test console event + const consolePromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for console event')), 10000); + page.on('console', (msg) => { + clearTimeout(timer); + resolve(msg.text()); + }); + }); + await page.evaluate(() => console.log('hello from puppeteer bidi')); + const consoleText = await consolePromise; + console.log('console event:', consoleText); + if (consoleText !== 'hello from puppeteer bidi') { + throw new Error(`expected console text "hello from puppeteer bidi", got "${consoleText}"`); + } + + // 2. Test navigation + title + await page.goto('https://example.com', { waitUntil: 'load' }); + console.log('navigation complete'); + + const title = await page.title(); + console.log('Title:', title); + if (!title.includes('Example Domain')) { + throw new Error(`expected title to contain "Example Domain", got "${title}"`); + } + + await browser.disconnect(); + console.log('All tests passed!'); +} + +main().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); + +function getArg(name, defaultValue) { + const idx = process.argv.indexOf(name); + if (idx !== -1 && idx + 1 < process.argv.length) { + return process.argv[idx + 1]; + } + return defaultValue; +} diff --git a/server/e2e/bidi/test-selenium-bidi.js b/server/e2e/bidi/test-selenium-bidi.js new file mode 100644 index 00000000..ade31a77 --- /dev/null +++ b/server/e2e/bidi/test-selenium-bidi.js @@ -0,0 +1,75 @@ +const { Builder, Browser } = require('selenium-webdriver'); +const chrome = require('selenium-webdriver/chrome'); +const LogInspector = require('selenium-webdriver/bidi/logInspector'); + +const endpoint = getArg('--endpoint', 'http://localhost:9224'); + +async function main() { + const options = new chrome.Options(); + options.enableBidi(); + + const driver = await new Builder() + .forBrowser(Browser.CHROME) + .setChromeOptions(options) + .usingServer(endpoint) + .setCapability('unhandledPromptBehavior', { default: 'ignore' }) + .build(); + + console.log('Session created'); + + // 1. Get browsing context + const handle = await driver.getWindowHandle(); + console.log('Window handle:', handle); + if (!handle) { + throw new Error('expected a non-empty window handle'); + } + + // 2. Test console log event subscription + const logInspector = await LogInspector(driver); + const consolePromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for console event')), 10000); + logInspector.onConsoleEntry((entry) => { + clearTimeout(timer); + resolve(entry); + }); + }); + await driver.executeScript("console.log('hello from selenium bidi')"); + const consoleEntry = await consolePromise; + console.log('console event:', consoleEntry.text); + if (consoleEntry.text !== 'hello from selenium bidi') { + throw new Error(`expected console text "hello from selenium bidi", got "${consoleEntry.text}"`); + } + + // 3. Navigate and verify title + await driver.get('https://example.com'); + const title = await driver.getTitle(); + console.log('Title:', title); + if (!title.includes('Example Domain')) { + throw new Error(`expected title to contain "Example Domain", got "${title}"`); + } + + // 4. Evaluate script + const result = await driver.executeScript('return document.title'); + console.log('executeScript document.title:', result); + if (!result.includes('Example Domain')) { + throw new Error(`expected executeScript result to contain "Example Domain", got "${result}"`); + } + + await logInspector.close(); + await driver.quit(); + console.log('All tests passed!'); + process.exit(0); +} + +main().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); + +function getArg(name, defaultValue) { + const idx = process.argv.indexOf(name); + if (idx !== -1 && idx + 1 < process.argv.length) { + return process.argv[idx + 1]; + } + return defaultValue; +} diff --git a/server/e2e/container.go b/server/e2e/container.go index ce7259e0..f5532935 100644 --- a/server/e2e/container.go +++ b/server/e2e/container.go @@ -17,11 +17,12 @@ import ( // TestContainer wraps testcontainers-go to manage a Docker container for e2e tests. // This enables parallel test execution by giving each test its own dynamically allocated ports. type TestContainer struct { - Name string - Image string - APIPort int // dynamically allocated host port -> container 10001 - CDPPort int // dynamically allocated host port -> container 9222 - ctr testcontainers.Container + Name string + Image string + APIPort int // dynamically allocated host port -> container 10001 + CDPPort int // dynamically allocated host port -> container 9222 + ChromeDriverPort int // dynamically allocated host port -> container 9224 + ctr testcontainers.Container } // ContainerConfig holds optional configuration for container startup. @@ -59,7 +60,7 @@ func (c *TestContainer) Start(ctx context.Context, cfg ContainerConfig) error { // Build container request options opts := []testcontainers.ContainerCustomizer{ testcontainers.WithImage(c.Image), - testcontainers.WithExposedPorts("10001/tcp", "9222/tcp"), + testcontainers.WithExposedPorts("10001/tcp", "9222/tcp", "9224/tcp"), testcontainers.WithEnv(env), testcontainers.WithTmpfs(map[string]string{"/dev/shm": "size=2g,mode=1777"}), // Set privileged mode for Chrome @@ -107,6 +108,12 @@ func (c *TestContainer) Start(ctx context.Context, cfg ContainerConfig) error { } c.CDPPort = cdpPort.Int() + chromeDriverPort, err := ctr.MappedPort(ctx, "9224/tcp") + if err != nil { + return fmt.Errorf("failed to get ChromeDriver port: %w", err) + } + c.ChromeDriverPort = chromeDriverPort.Int() + return nil } @@ -190,6 +197,35 @@ func (c *TestContainer) CDPAddr() string { return fmt.Sprintf("127.0.0.1:%d", c.CDPPort) } +// ChromeDriverURL returns the base HTTP URL for the container's ChromeDriver proxy. +func (c *TestContainer) ChromeDriverURL() string { + return fmt.Sprintf("http://127.0.0.1:%d", c.ChromeDriverPort) +} + +// WaitChromeDriver waits for the ChromeDriver proxy (and upstream ChromeDriver) +// to be ready by polling the /status endpoint. +func (c *TestContainer) WaitChromeDriver(ctx context.Context) error { + statusURL := c.ChromeDriverURL() + "/status" + client := &http.Client{Timeout: 2 * time.Second} + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + resp, err := client.Get(statusURL) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + // Exec executes a command inside the container and returns the combined output. func (c *TestContainer) Exec(ctx context.Context, cmd []string) (int, string, error) { exitCode, reader, err := c.ctr.Exec(ctx, cmd) diff --git a/server/e2e/e2e_bidi_test.go b/server/e2e/e2e_bidi_test.go new file mode 100644 index 00000000..12ef6bd1 --- /dev/null +++ b/server/e2e/e2e_bidi_test.go @@ -0,0 +1,474 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getBidiPath() string { + return "./bidi" +} + +func ensureBidiDeps(t *testing.T) { + t.Helper() + nodeModulesPath := getBidiPath() + "/node_modules" + if _, err := os.Stat(nodeModulesPath); os.IsNotExist(err) { + t.Log("Installing bidi test dependencies...") + cmd := exec.Command("npm", "install") + cmd.Dir = getBidiPath() + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to install bidi dependencies: %v\nOutput: %s", err, string(output)) + t.Log("Bidi test dependencies installed successfully") + } +} + +// bidiConn wraps a WebSocket connection for BiDi JSON-RPC communication. +// It runs a background read loop that dispatches command responses to pending +// callers and fires one-shot event listeners. +type bidiConn struct { + conn *websocket.Conn + ctx context.Context + nextID int + + mu sync.Mutex + pending map[int]chan bidiResult + + eventMu sync.Mutex + listeners []*eventListener +} + +type bidiResult struct { + Result json.RawMessage + Error json.RawMessage +} + +type eventListener struct { + method string + ch chan json.RawMessage +} + +func newBidiConn(ctx context.Context, conn *websocket.Conn) *bidiConn { + bc := &bidiConn{ + conn: conn, + ctx: ctx, + pending: make(map[int]chan bidiResult), + } + go bc.readLoop() + return bc +} + +func (bc *bidiConn) readLoop() { + for { + _, data, err := bc.conn.Read(bc.ctx) + if err != nil { + return + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + continue + } + + // Command response (has "id" field) + if idRaw, ok := raw["id"]; ok { + var id int + if json.Unmarshal(idRaw, &id) == nil { + bc.mu.Lock() + ch, found := bc.pending[id] + if found { + delete(bc.pending, id) + } + bc.mu.Unlock() + if found { + ch <- bidiResult{Result: raw["result"], Error: raw["error"]} + } + } + continue + } + + // Event (has "method" but no "id") + if methodRaw, ok := raw["method"]; ok { + var method string + if json.Unmarshal(methodRaw, &method) == nil { + bc.eventMu.Lock() + for i, l := range bc.listeners { + if l.method == method { + l.ch <- raw["params"] + bc.listeners = append(bc.listeners[:i], bc.listeners[i+1:]...) + break + } + } + bc.eventMu.Unlock() + } + } + } +} + +// send sends a BiDi command and blocks until the response arrives. +func (bc *bidiConn) send(method string, params interface{}) (json.RawMessage, error) { + bc.mu.Lock() + bc.nextID++ + id := bc.nextID + ch := make(chan bidiResult, 1) + bc.pending[id] = ch + bc.mu.Unlock() + + data, err := json.Marshal(map[string]interface{}{ + "id": id, "method": method, "params": params, + }) + if err != nil { + return nil, err + } + + if err := bc.conn.Write(bc.ctx, websocket.MessageText, data); err != nil { + return nil, err + } + + select { + case <-bc.ctx.Done(): + return nil, bc.ctx.Err() + case res := <-ch: + if len(res.Error) > 0 && string(res.Error) != "null" { + return nil, fmt.Errorf("bidi error: %s", string(res.Error)) + } + return res.Result, nil + } +} + +// addListener registers a one-shot event listener and returns a channel +// that receives the event params when the matching event fires. +func (bc *bidiConn) addListener(method string) <-chan json.RawMessage { + ch := make(chan json.RawMessage, 1) + bc.eventMu.Lock() + bc.listeners = append(bc.listeners, &eventListener{method: method, ch: ch}) + bc.eventMu.Unlock() + return ch +} + +// collectEvent waits for an event on the given channel with a timeout. +func collectEvent(t *testing.T, ch <-chan json.RawMessage, name string, timeout time.Duration) json.RawMessage { + t.Helper() + select { + case params := <-ch: + return params + case <-time.After(timeout): + t.Fatalf("timed out waiting for event: %s", name) + return nil + } +} + +// TestBidiWebSocket exercises the raw WebSocket BiDi protocol through the +// ChromeDriver proxy: session lifecycle, browsing context operations, event +// subscription, script evaluation, and navigation events. +func TestBidiWebSocket(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + require.NoError(t, c.WaitChromeDriver(ctx), "chromedriver not ready") + + // Connect to BiDi WebSocket endpoint + bidiURL := fmt.Sprintf("ws://127.0.0.1:%d/session", c.ChromeDriverPort) + t.Logf("connecting to BiDi endpoint: %s", bidiURL) + + conn, _, err := websocket.Dial(ctx, bidiURL, nil) + require.NoError(t, err, "failed to connect to BiDi WebSocket at %s", bidiURL) + defer conn.CloseNow() + conn.SetReadLimit(1 << 20) + + bc := newBidiConn(ctx, conn) + + // 1. session.new + result, err := bc.send("session.new", map[string]interface{}{ + "capabilities": map[string]interface{}{ + "alwaysMatch": map[string]interface{}{ + "webSocketUrl": true, + "unhandledPromptBehavior": map[string]string{"default": "ignore"}, + }, + }, + }) + require.NoError(t, err, "session.new failed") + + var session map[string]interface{} + require.NoError(t, json.Unmarshal(result, &session)) + require.NotEmpty(t, session["sessionId"], "session should have an ID") + t.Logf("session created: %v", session["sessionId"]) + + // 2. session.status + result, err = bc.send("session.status", map[string]interface{}{}) + require.NoError(t, err, "session.status failed") + t.Logf("session.status: %s", string(result)) + + // 3. browsingContext.getTree → extract context ID + result, err = bc.send("browsingContext.getTree", map[string]interface{}{}) + require.NoError(t, err, "browsingContext.getTree failed") + + var tree map[string]interface{} + require.NoError(t, json.Unmarshal(result, &tree)) + contexts, ok := tree["contexts"].([]interface{}) + require.True(t, ok && len(contexts) > 0, "should have at least one browsing context") + contextID, ok := contexts[0].(map[string]interface{})["context"].(string) + require.True(t, ok && contextID != "", "context ID should be a non-empty string") + t.Logf("context ID: %s", contextID) + + // 4. session.subscribe + _, err = bc.send("session.subscribe", map[string]interface{}{ + "events": []string{ + "log.entryAdded", + "browsingContext.domContentLoaded", + "browsingContext.load", + }, + }) + require.NoError(t, err, "session.subscribe failed") + + // 5. Verify log.entryAdded fires on console.log + logCh := bc.addListener("log.entryAdded") + + _, err = bc.send("script.evaluate", map[string]interface{}{ + "expression": "console.log('hello from bidi e2e test')", + "target": map[string]string{"context": contextID}, + "awaitPromise": false, + }) + require.NoError(t, err, "script.evaluate (console.log) failed") + + logParams := collectEvent(t, logCh, "log.entryAdded", 5*time.Second) + t.Logf("log.entryAdded received: %s", string(logParams)) + + // 6. Navigate and verify domContentLoaded + load events + dclCh := bc.addListener("browsingContext.domContentLoaded") + loadCh := bc.addListener("browsingContext.load") + + _, err = bc.send("browsingContext.navigate", map[string]interface{}{ + "context": contextID, + "url": "https://example.com", + "wait": "complete", + }) + require.NoError(t, err, "browsingContext.navigate failed") + + collectEvent(t, dclCh, "browsingContext.domContentLoaded", 15*time.Second) + t.Log("received browsingContext.domContentLoaded") + + collectEvent(t, loadCh, "browsingContext.load", 15*time.Second) + t.Log("received browsingContext.load") + + // 7. Verify page title via script.evaluate + result, err = bc.send("script.evaluate", map[string]interface{}{ + "expression": "document.title", + "target": map[string]string{"context": contextID}, + "awaitPromise": false, + }) + require.NoError(t, err, "script.evaluate (document.title) failed") + require.Contains(t, string(result), "Example Domain", + "page title should contain 'Example Domain', got: %s", string(result)) + t.Logf("document.title: %s", string(result)) + + // 8. session.unsubscribe + _, err = bc.send("session.unsubscribe", map[string]interface{}{ + "events": []string{ + "log.entryAdded", + "browsingContext.domContentLoaded", + "browsingContext.load", + }, + }) + require.NoError(t, err, "session.unsubscribe failed") + + // 9. session.end + _, err = bc.send("session.end", map[string]interface{}{}) + require.NoError(t, err, "session.end failed") + + conn.Close(websocket.StatusNormalClosure, "test complete") + t.Log("BiDi WebSocket test passed") +} + +// TestBidiHTTPSession tests the Selenium-style flow: HTTP POST /session to +// create a WebDriver session, verify the webSocketUrl is rewritten to point +// through the proxy, then connect via BiDi and run commands. +func TestBidiHTTPSession(t *testing.T) { + t.Parallel() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + require.NoError(t, c.WaitChromeDriver(ctx), "chromedriver not ready") + + chromeDriverURL := c.ChromeDriverURL() + + // POST /session to create a WebDriver session + sessionBody, err := json.Marshal(map[string]interface{}{ + "capabilities": map[string]interface{}{ + "alwaysMatch": map[string]interface{}{ + "browserName": "chrome", + "webSocketUrl": true, + "unhandledPromptBehavior": map[string]string{"default": "ignore"}, + }, + }, + }) + require.NoError(t, err) + + t.Logf("POST %s/session", chromeDriverURL) + resp, err := http.Post(chromeDriverURL+"/session", "application/json", bytes.NewReader(sessionBody)) + require.NoError(t, err, "POST /session request failed") + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 for POST /session") + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + t.Logf("POST /session response: %s", string(respBody)) + + // Parse response and extract session details + var sessionResp map[string]interface{} + require.NoError(t, json.Unmarshal(respBody, &sessionResp)) + + value, ok := sessionResp["value"].(map[string]interface{}) + require.True(t, ok, "response should have 'value' object") + sessionID, ok := value["sessionId"].(string) + require.True(t, ok && sessionID != "", "session ID should be a non-empty string") + + caps, ok := value["capabilities"].(map[string]interface{}) + require.True(t, ok, "response should have 'capabilities'") + wsURL, ok := caps["webSocketUrl"].(string) + require.True(t, ok && wsURL != "", "webSocketUrl should be present in capabilities") + + t.Logf("session ID: %s, webSocketUrl: %s", sessionID, wsURL) + + // Verify the proxy rewrote webSocketUrl to point through itself + expectedHost := fmt.Sprintf("127.0.0.1:%d", c.ChromeDriverPort) + require.Contains(t, wsURL, expectedHost, + "webSocketUrl should point through the proxy (expected host %s), got: %s", expectedHost, wsURL) + + // Connect to the BiDi WebSocket on the returned URL + conn, _, err := websocket.Dial(ctx, wsURL, nil) + require.NoError(t, err, "failed to connect to BiDi via webSocketUrl: %s", wsURL) + defer conn.CloseNow() + conn.SetReadLimit(1 << 20) + + bc := newBidiConn(ctx, conn) + + // browsingContext.getTree should work on the existing session + result, err := bc.send("browsingContext.getTree", map[string]interface{}{}) + require.NoError(t, err, "browsingContext.getTree failed on HTTP session") + + var tree map[string]interface{} + require.NoError(t, json.Unmarshal(result, &tree)) + contexts, ok := tree["contexts"].([]interface{}) + require.True(t, ok && len(contexts) > 0, "should have at least one browsing context") + contextID, ok := contexts[0].(map[string]interface{})["context"].(string) + require.True(t, ok && contextID != "", "context ID should be a non-empty string") + t.Logf("context ID: %s", contextID) + + // script.evaluate should work + result, err = bc.send("script.evaluate", map[string]interface{}{ + "expression": "'hello from HTTP session BiDi'", + "target": map[string]string{"context": contextID}, + "awaitPromise": false, + }) + require.NoError(t, err, "script.evaluate failed on HTTP session") + require.Contains(t, string(result), "hello from HTTP session BiDi") + t.Logf("script.evaluate result: %s", string(result)) + + conn.Close(websocket.StatusNormalClosure, "test complete") + + // Clean up: DELETE /session/{id} + delReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, + fmt.Sprintf("%s/session/%s", chromeDriverURL, sessionID), nil) + require.NoError(t, err) + + delResp, err := http.DefaultClient.Do(delReq) + require.NoError(t, err, "DELETE /session request failed") + delResp.Body.Close() + assert.Equal(t, http.StatusOK, delResp.StatusCode, "expected 200 for DELETE /session") + + t.Log("BiDi HTTP session test passed") +} + +// TestBidiPuppeteer exercises Puppeteer's webDriverBiDi protocol through +// the ChromeDriver proxy by running the test-puppeteer-bidi.js script. +func TestBidiPuppeteer(t *testing.T) { + t.Parallel() + ensureBidiDeps(t) + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + require.NoError(t, c.WaitChromeDriver(ctx), "chromedriver not ready") + + endpoint := fmt.Sprintf("ws://127.0.0.1:%d/session", c.ChromeDriverPort) + t.Logf("running test-puppeteer-bidi.js against %s", endpoint) + + cmd := exec.CommandContext(ctx, "node", "test-puppeteer-bidi.js", "--endpoint", endpoint) + cmd.Dir = getBidiPath() + out, err := cmd.CombinedOutput() + t.Logf("test-puppeteer-bidi.js output:\n%s", string(out)) + require.NoError(t, err, "test-puppeteer-bidi.js failed: %v", err) +} + +// TestBidiSelenium exercises Selenium WebDriver's BiDi support through +// the ChromeDriver proxy by running the test-selenium-bidi.js script. +func TestBidiSelenium(t *testing.T) { + t.Parallel() + ensureBidiDeps(t) + + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + require.NoError(t, c.WaitChromeDriver(ctx), "chromedriver not ready") + + endpoint := fmt.Sprintf("http://127.0.0.1:%d", c.ChromeDriverPort) + t.Logf("running test-selenium-bidi.js against %s", endpoint) + + cmd := exec.CommandContext(ctx, "node", "test-selenium-bidi.js", "--endpoint", endpoint) + cmd.Dir = getBidiPath() + out, err := cmd.CombinedOutput() + t.Logf("test-selenium-bidi.js output:\n%s", string(out)) + require.NoError(t, err, "test-selenium-bidi.js failed: %v", err) +} diff --git a/server/lib/chromedriverproxy/proxy.go b/server/lib/chromedriverproxy/proxy.go new file mode 100644 index 00000000..fe4a2f4f --- /dev/null +++ b/server/lib/chromedriverproxy/proxy.go @@ -0,0 +1,251 @@ +package chromedriverproxy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/coder/websocket" + "github.com/onkernel/kernel-images/server/lib/wsproxy" +) + +var ( + chromeDriverAddr = "127.0.0.1:9225" + debuggerAddr = "127.0.0.1:9222" +) + +// Handler returns an http.Handler that proxies all requests to the upstream +// ChromeDriver instance at 127.0.0.1:9225. For POST /session it injects the +// debuggerAddress capability pointing to the devtools proxy (127.0.0.1:9222) +// so ChromeDriver attaches to the already-running Chrome. WebSocket upgrade +// requests (used by WebDriver BiDi) are proxied bidirectionally. +func Handler(logger *slog.Logger) http.Handler { + upstream, _ := url.Parse("http://" + chromeDriverAddr) + + reverseProxy := &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(upstream) + r.Out.Host = r.In.Host + }, + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isWebSocketUpgrade(r) { + proxyWebSocket(w, r, logger) + return + } + + if r.Method == http.MethodPost && r.URL.Path == "/session" { + handleCreateSession(w, r, logger) + return + } + + reverseProxy.ServeHTTP(w, r) + }) +} + +func isWebSocketUpgrade(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Connection"), "upgrade") && + strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} + +// handleCreateSession intercepts POST /session, injects goog:chromeOptions +// with debuggerAddress pointing to the devtools proxy, and forwards to ChromeDriver. +// It also rewrites the webSocketUrl in the response to point back through the proxy. +func handleCreateSession(w http.ResponseWriter, r *http.Request, logger *slog.Logger) { + body, err := io.ReadAll(r.Body) + r.Body.Close() + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + + injectDebuggerAddress(payload, debuggerAddr) + + rewritten, err := json.Marshal(payload) + if err != nil { + http.Error(w, "failed to encode modified body", http.StatusInternalServerError) + return + } + + logger.Info("chromedriver proxy: injected debuggerAddress into POST /session", + slog.String("rewritten", string(rewritten))) + + upstreamURL := fmt.Sprintf("http://%s/session", chromeDriverAddr) + proxyReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, upstreamURL, bytes.NewReader(rewritten)) + if err != nil { + http.Error(w, "failed to create upstream request", http.StatusInternalServerError) + return + } + for k, vv := range r.Header { + for _, v := range vv { + proxyReq.Header.Add(k, v) + } + } + proxyReq.Header.Set("Content-Length", fmt.Sprintf("%d", len(rewritten))) + proxyReq.ContentLength = int64(len(rewritten)) + + resp, err := http.DefaultClient.Do(proxyReq) + if err != nil { + logger.Error("chromedriver proxy: upstream POST /session failed", slog.String("err", err.Error())) + http.Error(w, "upstream request failed", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, "failed to read upstream response", http.StatusBadGateway) + return + } + + respBody = rewriteWebSocketURL(respBody, r.Host, logger) + + for k, vv := range resp.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(respBody))) + w.WriteHeader(resp.StatusCode) + w.Write(respBody) +} + +// rewriteWebSocketURL rewrites value.capabilities.webSocketUrl in a session +// creation response so it points back through the proxy instead of directly +// to ChromeDriver. +func rewriteWebSocketURL(body []byte, proxyHost string, logger *slog.Logger) []byte { + var respPayload map[string]interface{} + if err := json.Unmarshal(body, &respPayload); err != nil { + return body + } + + value, ok := respPayload["value"].(map[string]interface{}) + if !ok { + return body + } + caps, ok := value["capabilities"].(map[string]interface{}) + if !ok { + return body + } + ws, ok := caps["webSocketUrl"].(string) + if !ok { + return body + } + + parsed, err := url.Parse(ws) + if err != nil { + return body + } + parsed.Host = proxyHost + caps["webSocketUrl"] = parsed.String() + + out, err := json.Marshal(respPayload) + if err != nil { + logger.Error("chromedriver proxy: failed to re-encode response", slog.String("err", err.Error())) + return body + } + + logger.Info("chromedriver proxy: rewrote webSocketUrl", + slog.String("original", ws), slog.String("rewritten", parsed.String())) + return out +} + +// injectDebuggerAddress sets goog:chromeOptions.debuggerAddress in +// capabilities.alwaysMatch, which ChromeDriver merges into every candidate. +func injectDebuggerAddress(payload map[string]interface{}, addr string) { + caps, ok := payload["capabilities"].(map[string]interface{}) + if !ok { + caps = map[string]interface{}{} + payload["capabilities"] = caps + } + + alwaysMatch, ok := caps["alwaysMatch"].(map[string]interface{}) + if !ok { + alwaysMatch = map[string]interface{}{} + caps["alwaysMatch"] = alwaysMatch + } + setChromeOption(alwaysMatch, "debuggerAddress", addr) +} + +func setChromeOption(caps map[string]interface{}, key, value string) { + opts, ok := caps["goog:chromeOptions"].(map[string]interface{}) + if !ok { + opts = map[string]interface{}{} + caps["goog:chromeOptions"] = opts + } + opts[key] = value +} + +// proxyWebSocket handles WebSocket upgrade requests by proxying them +// bidirectionally to the upstream ChromeDriver, preserving the request path. +// Client-to-upstream messages are inspected for BiDi session.new commands; +// when found, debuggerAddress is injected into the capabilities. +func proxyWebSocket(w http.ResponseWriter, r *http.Request, logger *slog.Logger) { + upstreamURL := (&url.URL{ + Scheme: "ws", + Host: chromeDriverAddr, + Path: r.URL.Path, + RawQuery: r.URL.RawQuery, + }).String() + acceptOpts := &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + CompressionMode: websocket.CompressionContextTakeover, + } + dialOpts := &websocket.DialOptions{ + CompressionMode: websocket.CompressionContextTakeover, + } + + transform := func(direction string, mt websocket.MessageType, msg []byte) []byte { + if direction != "->" || mt != websocket.MessageText { + return msg + } + return maybeInjectBidiSession(msg, logger) + } + + wsproxy.Proxy(w, r, upstreamURL, acceptOpts, dialOpts, logger, transform) +} + +// maybeInjectBidiSession checks if a WebSocket message is a BiDi session.new +// command and injects debuggerAddress into its capabilities if so. +func maybeInjectBidiSession(msg []byte, logger *slog.Logger) []byte { + var bidiMsg map[string]interface{} + if err := json.Unmarshal(msg, &bidiMsg); err != nil { + return msg + } + + method, _ := bidiMsg["method"].(string) + if method != "session.new" { + return msg + } + + params, ok := bidiMsg["params"].(map[string]interface{}) + if !ok { + params = map[string]interface{}{} + bidiMsg["params"] = params + } + + injectDebuggerAddress(params, debuggerAddr) + + rewritten, err := json.Marshal(bidiMsg) + if err != nil { + logger.Error("chromedriver proxy: failed to re-encode session.new", slog.String("err", err.Error())) + return msg + } + + logger.Info("chromedriver proxy: injected debuggerAddress into BiDi session.new", + slog.String("debuggerAddress", debuggerAddr)) + return rewritten +} diff --git a/server/lib/chromedriverproxy/proxy_test.go b/server/lib/chromedriverproxy/proxy_test.go new file mode 100644 index 00000000..bf8a4e9f --- /dev/null +++ b/server/lib/chromedriverproxy/proxy_test.go @@ -0,0 +1,318 @@ +package chromedriverproxy + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func silentLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// withChromeDriverAddr temporarily overrides the package-level chromeDriverAddr +// for testing, restoring the original value when the test completes. +func withChromeDriverAddr(t *testing.T, addr string) { + t.Helper() + orig := chromeDriverAddr + chromeDriverAddr = addr + t.Cleanup(func() { chromeDriverAddr = orig }) +} + +func TestInjectDebuggerAddress_EmptyPayload(t *testing.T) { + payload := map[string]interface{}{} + injectDebuggerAddress(payload, "127.0.0.1:9222") + + caps := payload["capabilities"].(map[string]interface{}) + alwaysMatch := caps["alwaysMatch"].(map[string]interface{}) + opts := alwaysMatch["goog:chromeOptions"].(map[string]interface{}) + assert.Equal(t, "127.0.0.1:9222", opts["debuggerAddress"]) +} + +func TestInjectDebuggerAddress_ExistingCapabilities(t *testing.T) { + payload := map[string]interface{}{ + "capabilities": map[string]interface{}{ + "alwaysMatch": map[string]interface{}{ + "goog:chromeOptions": map[string]interface{}{ + "args": []interface{}{"--headless"}, + }, + }, + }, + } + injectDebuggerAddress(payload, "127.0.0.1:9222") + + caps := payload["capabilities"].(map[string]interface{}) + alwaysMatch := caps["alwaysMatch"].(map[string]interface{}) + opts := alwaysMatch["goog:chromeOptions"].(map[string]interface{}) + assert.Equal(t, "127.0.0.1:9222", opts["debuggerAddress"]) + assert.Equal(t, []interface{}{"--headless"}, opts["args"], "existing options should be preserved") +} + +func TestInjectDebuggerAddress_OverridesExisting(t *testing.T) { + payload := map[string]interface{}{ + "capabilities": map[string]interface{}{ + "alwaysMatch": map[string]interface{}{ + "goog:chromeOptions": map[string]interface{}{ + "debuggerAddress": "old:1234", + }, + }, + }, + } + injectDebuggerAddress(payload, "127.0.0.1:9222") + + caps := payload["capabilities"].(map[string]interface{}) + alwaysMatch := caps["alwaysMatch"].(map[string]interface{}) + opts := alwaysMatch["goog:chromeOptions"].(map[string]interface{}) + assert.Equal(t, "127.0.0.1:9222", opts["debuggerAddress"]) +} + +func TestHandler_PostSession_InjectsDebuggerAddress(t *testing.T) { + var capturedBody []byte + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedBody = body + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := `{"value":{"sessionId":"abc123","capabilities":{"webSocketUrl":"ws://127.0.0.1:9225/session/abc123"}}}` + w.Write([]byte(resp)) + })) + defer backend.Close() + + backendURL, _ := url.Parse(backend.URL) + withChromeDriverAddr(t, backendURL.Host) + + handler := Handler(silentLogger()) + + reqBody := `{"capabilities":{"alwaysMatch":{"browserName":"chrome"}}}` + req := httptest.NewRequest(http.MethodPost, "/session", strings.NewReader(reqBody)) + req.Host = "127.0.0.1:9224" + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, capturedBody) + + var received map[string]interface{} + require.NoError(t, json.Unmarshal(capturedBody, &received)) + + caps := received["capabilities"].(map[string]interface{}) + alwaysMatch := caps["alwaysMatch"].(map[string]interface{}) + opts := alwaysMatch["goog:chromeOptions"].(map[string]interface{}) + assert.Equal(t, debuggerAddr, opts["debuggerAddress"]) + assert.Equal(t, "chrome", alwaysMatch["browserName"], "original capabilities preserved") + + var respBody map[string]interface{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &respBody)) + value := respBody["value"].(map[string]interface{}) + assert.Equal(t, "abc123", value["sessionId"]) + respCaps := value["capabilities"].(map[string]interface{}) + assert.Equal(t, "ws://127.0.0.1:9224/session/abc123", respCaps["webSocketUrl"], + "webSocketUrl in capabilities should be rewritten to proxy address") +} + +func TestHandler_HTTPPassthrough(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := map[string]interface{}{ + "path": r.URL.Path, + "method": r.Method, + } + json.NewEncoder(w).Encode(resp) + })) + defer backend.Close() + + backendURL, _ := url.Parse(backend.URL) + withChromeDriverAddr(t, backendURL.Host) + + handler := Handler(silentLogger()) + + tests := []struct { + method string + path string + }{ + {http.MethodGet, "/status"}, + {http.MethodGet, "/session/abc123"}, + {http.MethodPost, "/session/abc123/url"}, + {http.MethodDelete, "/session/abc123"}, + {http.MethodPost, "/session/abc123/element"}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + var body io.Reader + if tt.method == http.MethodPost { + body = strings.NewReader(`{"url":"https://example.com"}`) + } + req := httptest.NewRequest(tt.method, tt.path, body) + if tt.method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, tt.path, resp["path"]) + assert.Equal(t, tt.method, resp["method"]) + }) + } +} + +func TestHandler_WebSocketPassthrough(t *testing.T) { + echoBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}) + if err != nil { + return + } + defer c.Close(websocket.StatusNormalClosure, "") + + ctx := r.Context() + for { + mt, msg, err := c.Read(ctx) + if err != nil { + return + } + if err := c.Write(ctx, mt, msg); err != nil { + return + } + } + })) + defer echoBackend.Close() + + backendURL, _ := url.Parse(echoBackend.URL) + withChromeDriverAddr(t, backendURL.Host) + + handler := Handler(silentLogger()) + proxySrv := httptest.NewServer(handler) + defer proxySrv.Close() + + proxyURL, _ := url.Parse(proxySrv.URL) + proxyURL.Scheme = "ws" + proxyURL.Path = "/session/abc123" + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, proxyURL.String(), nil) + require.NoError(t, err) + defer conn.Close(websocket.StatusNormalClosure, "") + + msg := "hello bidi" + require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(msg))) + + _, resp, err := conn.Read(ctx) + require.NoError(t, err) + assert.Equal(t, msg, string(resp)) +} + +func TestHandler_WebSocket_BiDiSessionNew_InjectsDebuggerAddress(t *testing.T) { + var capturedMsg []byte + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}) + if err != nil { + return + } + defer c.Close(websocket.StatusNormalClosure, "") + + ctx := r.Context() + mt, msg, err := c.Read(ctx) + if err != nil { + return + } + capturedMsg = msg + c.Write(ctx, mt, []byte(`{"id":1,"type":"success","result":{}}`)) + })) + defer backend.Close() + + backendURL, _ := url.Parse(backend.URL) + withChromeDriverAddr(t, backendURL.Host) + + handler := Handler(silentLogger()) + proxySrv := httptest.NewServer(handler) + defer proxySrv.Close() + + proxyURL, _ := url.Parse(proxySrv.URL) + proxyURL.Scheme = "ws" + proxyURL.Path = "/session" + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, proxyURL.String(), nil) + require.NoError(t, err) + defer conn.Close(websocket.StatusNormalClosure, "") + + bidiCmd := `{"id":1,"method":"session.new","params":{"capabilities":{"alwaysMatch":{"webSocketUrl":true}}}}` + require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(bidiCmd))) + + _, _, err = conn.Read(ctx) + require.NoError(t, err) + require.NotNil(t, capturedMsg) + + var received map[string]interface{} + require.NoError(t, json.Unmarshal(capturedMsg, &received)) + + params := received["params"].(map[string]interface{}) + caps := params["capabilities"].(map[string]interface{}) + alwaysMatch := caps["alwaysMatch"].(map[string]interface{}) + opts := alwaysMatch["goog:chromeOptions"].(map[string]interface{}) + assert.Equal(t, debuggerAddr, opts["debuggerAddress"]) + assert.Equal(t, true, alwaysMatch["webSocketUrl"], "original capabilities preserved") +} + +func TestHandler_WebSocket_NonSessionNew_PassesThrough(t *testing.T) { + var capturedMsg []byte + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}) + if err != nil { + return + } + defer c.Close(websocket.StatusNormalClosure, "") + + ctx := r.Context() + mt, msg, err := c.Read(ctx) + if err != nil { + return + } + capturedMsg = msg + c.Write(ctx, mt, msg) + })) + defer backend.Close() + + backendURL, _ := url.Parse(backend.URL) + withChromeDriverAddr(t, backendURL.Host) + + handler := Handler(silentLogger()) + proxySrv := httptest.NewServer(handler) + defer proxySrv.Close() + + proxyURL, _ := url.Parse(proxySrv.URL) + proxyURL.Scheme = "ws" + proxyURL.Path = "/session" + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, proxyURL.String(), nil) + require.NoError(t, err) + defer conn.Close(websocket.StatusNormalClosure, "") + + bidiCmd := `{"id":2,"method":"browsingContext.getTree","params":{}}` + require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(bidiCmd))) + + _, resp, err := conn.Read(ctx) + require.NoError(t, err) + + assert.Equal(t, bidiCmd, string(capturedMsg), "non-session.new messages should pass through unmodified") + assert.Equal(t, bidiCmd, string(resp)) +} diff --git a/server/lib/devtoolsproxy/proxy.go b/server/lib/devtoolsproxy/proxy.go index 19d84f61..af8a396d 100644 --- a/server/lib/devtoolsproxy/proxy.go +++ b/server/lib/devtoolsproxy/proxy.go @@ -18,6 +18,7 @@ import ( "github.com/coder/websocket" "github.com/onkernel/kernel-images/server/lib/scaletozero" + "github.com/onkernel/kernel-images/server/lib/wsproxy" ) var devtoolsListeningRegexp = regexp.MustCompile(`DevTools listening on (ws://\S+)`) @@ -198,7 +199,6 @@ func (u *UpstreamManager) runTailOnce(ctx context.Context) { // If logCDPMessages is true, all CDP messages will be logged with their direction. func WebSocketProxyHandler(mgr *UpstreamManager, logger *slog.Logger, logCDPMessages bool, ctrl scaletozero.Controller) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamCurrent := mgr.Current() if upstreamCurrent == "" { http.Error(w, "upstream not ready", http.StatusServiceUnavailable) @@ -209,49 +209,27 @@ func WebSocketProxyHandler(mgr *UpstreamManager, logger *slog.Logger, logCDPMess http.Error(w, "invalid upstream", http.StatusInternalServerError) return } - // Always use the full upstream path and query, ignoring the client's request path/query upstreamURL := (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host, Path: parsed.Path, RawQuery: parsed.RawQuery}).String() - acceptOptions := &websocket.AcceptOptions{ + + var transform wsproxy.MessageTransform + if logCDPMessages { + transform = func(direction string, mt websocket.MessageType, msg []byte) []byte { + logCDPMessage(logger, direction, mt, msg) + return msg + } + } + + acceptOpts := &websocket.AcceptOptions{ OriginPatterns: []string{"*"}, CompressionMode: websocket.CompressionContextTakeover, } - logger.Info("accept options", slog.Any("options", acceptOptions)) - clientConn, err := websocket.Accept(w, r, acceptOptions) - if err != nil { - logger.Error("websocket accept failed", slog.String("err", err.Error())) - return - } - clientConn.SetReadLimit(100 * 1024 * 1024) // 100 MB. Effectively no maximum size of message from client - dialOptions := &websocket.DialOptions{ + dialOpts := &websocket.DialOptions{ CompressionMode: websocket.CompressionContextTakeover, } - logger.Info("dial options", slog.Any("options", dialOptions)) - upstreamConn, _, err := websocket.Dial(r.Context(), upstreamURL, dialOptions) - if err != nil { - logger.Error("dial upstream failed", slog.String("err", err.Error()), slog.String("url", upstreamURL)) - _ = clientConn.Close(websocket.StatusInternalError, "failed to connect to upstream") - return - } - upstreamConn.SetReadLimit(100 * 1024 * 1024) // 100 MB. Effectively no maximum size of message from upstream - logger.Debug("proxying devtools websocket", slog.String("url", upstreamURL)) - - var once sync.Once - cleanup := func() { - once.Do(func() { - _ = upstreamConn.Close(websocket.StatusNormalClosure, "") - _ = clientConn.Close(websocket.StatusNormalClosure, "") - }) - } - proxyWebSocket(r.Context(), clientConn, upstreamConn, cleanup, logger, logCDPMessages) + wsproxy.Proxy(w, r, upstreamURL, acceptOpts, dialOpts, logger, transform) }) } -type wsConn interface { - Read(ctx context.Context) (websocket.MessageType, []byte, error) - Write(ctx context.Context, typ websocket.MessageType, p []byte) error - Close(statusCode websocket.StatusCode, reason string) error -} - // logCDPMessage logs a CDP message with its direction if logging is enabled func logCDPMessage(logger *slog.Logger, direction string, mt websocket.MessageType, msg []byte) { if mt != websocket.MessageText { @@ -329,56 +307,3 @@ func logCDPMessage(logger *slog.Logger, direction string, mt websocket.MessageTy logger.Info("cdp", args...) } - -func proxyWebSocket(ctx context.Context, clientConn, upstreamConn wsConn, onClose func(), logger *slog.Logger, logCDPMessages bool) { - errChan := make(chan error, 2) - - go func() { - for { - mt, msg, err := clientConn.Read(ctx) - if err != nil { - logger.Error("client read error", slog.String("err", err.Error())) - errChan <- err - break - } - - // Log CDP messages if enabled - if logCDPMessages { - logCDPMessage(logger, "->", mt, msg) - } - - if err := upstreamConn.Write(ctx, mt, msg); err != nil { - logger.Error("upstream write error", slog.String("err", err.Error())) - errChan <- err - break - } - } - }() - go func() { - for { - mt, msg, err := upstreamConn.Read(ctx) - if err != nil { - logger.Error("upstream read error", slog.String("err", err.Error())) - errChan <- err - break - } - - // Log CDP messages if enabled - if logCDPMessages { - logCDPMessage(logger, "<-", mt, msg) - } - - if err := clientConn.Write(ctx, mt, msg); err != nil { - logger.Error("client write error", slog.String("err", err.Error())) - errChan <- err - break - } - } - }() - - select { - case <-ctx.Done(): - case <-errChan: - } - onClose() -} diff --git a/server/lib/wsproxy/wsproxy.go b/server/lib/wsproxy/wsproxy.go new file mode 100644 index 00000000..5fc37510 --- /dev/null +++ b/server/lib/wsproxy/wsproxy.go @@ -0,0 +1,110 @@ +package wsproxy + +import ( + "context" + "log/slog" + "net/http" + "sync" + + "github.com/coder/websocket" +) + +// Conn abstracts a WebSocket connection for testing and flexibility. +type Conn interface { + Read(ctx context.Context) (websocket.MessageType, []byte, error) + Write(ctx context.Context, typ websocket.MessageType, p []byte) error + Close(statusCode websocket.StatusCode, reason string) error +} + +// MessageTransform is called for every message flowing through the proxy. +// direction is "->" for client-to-upstream and "<-" for upstream-to-client. +// It returns the (possibly modified) message bytes to forward. +type MessageTransform func(direction string, mt websocket.MessageType, msg []byte) []byte + +// Pump bidirectionally copies messages between client and upstream until +// either side errors or ctx is cancelled, then calls onClose. +// If transform is non-nil it is called for every message; the returned bytes +// are forwarded to the other side. +func Pump(ctx context.Context, client, upstream Conn, onClose func(), logger *slog.Logger, transform MessageTransform) { + errChan := make(chan error, 2) + + go func() { + for { + mt, msg, err := client.Read(ctx) + if err != nil { + logger.Error("client read error", slog.String("err", err.Error())) + errChan <- err + return + } + if transform != nil { + msg = transform("->", mt, msg) + } + if err := upstream.Write(ctx, mt, msg); err != nil { + logger.Error("upstream write error", slog.String("err", err.Error())) + errChan <- err + return + } + } + }() + + go func() { + for { + mt, msg, err := upstream.Read(ctx) + if err != nil { + logger.Error("upstream read error", slog.String("err", err.Error())) + errChan <- err + return + } + if transform != nil { + msg = transform("<-", mt, msg) + } + if err := client.Write(ctx, mt, msg); err != nil { + logger.Error("client write error", slog.String("err", err.Error())) + errChan <- err + return + } + } + }() + + select { + case <-ctx.Done(): + case <-errChan: + } + onClose() +} + +// Proxy accepts a client WebSocket upgrade, dials the upstream URL, and pumps +// messages bidirectionally until either side closes. acceptOpts and dialOpts +// may be nil for defaults. If transform is non-nil it is called for every +// message and the returned bytes are forwarded. +func Proxy(w http.ResponseWriter, r *http.Request, upstreamURL string, acceptOpts *websocket.AcceptOptions, dialOpts *websocket.DialOptions, logger *slog.Logger, transform MessageTransform) { + if acceptOpts == nil { + acceptOpts = &websocket.AcceptOptions{OriginPatterns: []string{"*"}} + } + clientConn, err := websocket.Accept(w, r, acceptOpts) + if err != nil { + logger.Error("websocket accept failed", slog.String("err", err.Error())) + return + } + clientConn.SetReadLimit(100 * 1024 * 1024) + + upstreamConn, _, err := websocket.Dial(r.Context(), upstreamURL, dialOpts) + if err != nil { + logger.Error("dial upstream failed", slog.String("err", err.Error()), slog.String("url", upstreamURL)) + clientConn.Close(websocket.StatusInternalError, "failed to connect to upstream") + return + } + upstreamConn.SetReadLimit(100 * 1024 * 1024) + + logger.Debug("proxying websocket", slog.String("url", upstreamURL)) + + var once sync.Once + cleanup := func() { + once.Do(func() { + upstreamConn.Close(websocket.StatusNormalClosure, "") + clientConn.Close(websocket.StatusNormalClosure, "") + }) + } + + Pump(r.Context(), clientConn, upstreamConn, cleanup, logger, transform) +}