diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 974c733c..f70cf776 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -146,7 +146,7 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX rm -rf /tmp/ffmpeg* EOT -FROM ghcr.io/onkernel/neko/base:3.0.8-v1.3.0 AS neko +FROM ghcr.io/kernel/neko/base:3.0.8-v1.3.0 AS neko # ^--- now has event.SYSTEM_PONG with legacy support to keepalive FROM node:22-bullseye-slim AS node-22 FROM docker.io/ubuntu:22.04 @@ -276,8 +276,8 @@ RUN set -eux; \ ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \ fi -# Install TypeScript, Playwright, Patchright globally -RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright tsx +# Install TypeScript, Playwright, Patchright, esbuild globally +RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright esbuild # setup desktop env & app ENV DISPLAY_NUM=1 @@ -303,8 +303,18 @@ COPY images/chromium-headful/supervisor/services/ /etc/supervisor/conf.d/service COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher -# Copy the Playwright executor runtime -COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts +# Copy and compile the Playwright daemon +COPY server/runtime/playwright-daemon.ts /tmp/playwright-daemon.ts +RUN esbuild /tmp/playwright-daemon.ts \ + --bundle \ + --platform=node \ + --target=node22 \ + --format=esm \ + --outfile=/usr/local/lib/playwright-daemon.js \ + --external:playwright-core \ + --external:patchright \ + --external:esbuild \ + && rm /tmp/playwright-daemon.ts RUN useradd -m -s /bin/bash kernel diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index f00ad08c..04de1162 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -179,8 +179,8 @@ RUN set -eux; \ ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack; \ fi -# Install TypeScript, Playwright, Patchright globally -RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright tsx +# Install TypeScript, Playwright, Patchright, esbuild globally +RUN --mount=type=cache,target=/root/.npm,id=$CACHEIDPREFIX-npm npm install -g typescript playwright-core patchright esbuild ENV WITHDOCKER=true @@ -202,7 +202,17 @@ COPY images/chromium-headless/image/supervisor/services/ /etc/supervisor/conf.d/ COPY --from=server-builder /out/kernel-images-api /usr/local/bin/kernel-images-api COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launcher -# Copy the Playwright executor runtime -COPY server/runtime/playwright-executor.ts /usr/local/lib/playwright-executor.ts +# Copy and compile the Playwright daemon +COPY server/runtime/playwright-daemon.ts /tmp/playwright-daemon.ts +RUN esbuild /tmp/playwright-daemon.ts \ + --bundle \ + --platform=node \ + --target=node22 \ + --format=esm \ + --outfile=/usr/local/lib/playwright-daemon.js \ + --external:playwright-core \ + --external:patchright \ + --external:esbuild \ + && rm /tmp/playwright-daemon.ts ENTRYPOINT [ "/usr/bin/wrapper.sh" ] diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 3c410242..910410b9 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "sync" "time" @@ -44,6 +45,12 @@ type ApiService struct { // playwrightMu serializes Playwright code execution (only one execution at a time) playwrightMu sync.Mutex + // playwrightDaemonStarting is an atomic flag to prevent concurrent daemon starts + playwrightDaemonStarting int32 + + // playwrightDaemonCmd holds the daemon process for cleanup + playwrightDaemonCmd *exec.Cmd + // policy management policy *policy.Policy } diff --git a/server/cmd/api/api/playwright.go b/server/cmd/api/api/playwright.go index d18e1e07..2e8667b8 100644 --- a/server/cmd/api/api/playwright.go +++ b/server/cmd/api/api/playwright.go @@ -1,26 +1,141 @@ package api import ( + "bufio" "context" "encoding/json" "fmt" + "net" "os" "os/exec" + "sync/atomic" "time" + "github.com/google/uuid" "github.com/onkernel/kernel-images/server/lib/logger" "github.com/onkernel/kernel-images/server/lib/oapi" ) -// ExecutePlaywrightCode implements the Playwright code execution endpoint +const ( + playwrightDaemonSocket = "/tmp/playwright-daemon.sock" + playwrightDaemonScript = "/usr/local/lib/playwright-daemon.js" + playwrightDaemonStartup = 5 * time.Second +) + +type playwrightDaemonRequest struct { + ID string `json:"id"` + Code string `json:"code"` + TimeoutMs int `json:"timeout_ms,omitempty"` +} + +type playwrightDaemonResponse struct { + ID string `json:"id"` + Success bool `json:"success"` + Result interface{} `json:"result,omitempty"` + Error string `json:"error,omitempty"` + Stack string `json:"stack,omitempty"` +} + +func (s *ApiService) ensurePlaywrightDaemon(ctx context.Context) error { + log := logger.FromContext(ctx) + + if conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 100*time.Millisecond); err == nil { + conn.Close() + return nil + } + + if !atomic.CompareAndSwapInt32(&s.playwrightDaemonStarting, 0, 1) { + deadline := time.Now().Add(playwrightDaemonStartup) + for time.Now().Before(deadline) { + if conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 100*time.Millisecond); err == nil { + conn.Close() + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("timeout waiting for daemon to start") + } + defer atomic.StoreInt32(&s.playwrightDaemonStarting, 0) + + log.Info("starting playwright daemon") + + cmd := exec.Command("node", playwrightDaemonScript) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start playwright daemon: %w", err) + } + + s.playwrightDaemonCmd = cmd + + deadline := time.Now().Add(playwrightDaemonStartup) + for time.Now().Before(deadline) { + if conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 100*time.Millisecond); err == nil { + conn.Close() + log.Info("playwright daemon started successfully") + return nil + } + time.Sleep(100 * time.Millisecond) + } + + cmd.Process.Kill() + return fmt.Errorf("playwright daemon failed to start within %v", playwrightDaemonStartup) +} + +func (s *ApiService) executeViaUnixSocket(ctx context.Context, code string, timeout time.Duration) (*playwrightDaemonResponse, error) { + conn, err := net.DialTimeout("unix", playwrightDaemonSocket, 2*time.Second) + if err != nil { + return nil, fmt.Errorf("failed to connect to daemon: %w", err) + } + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(timeout + 5*time.Second)); err != nil { + return nil, fmt.Errorf("failed to set deadline: %w", err) + } + + reqID := uuid.New().String() + req := playwrightDaemonRequest{ + ID: reqID, + Code: code, + TimeoutMs: int(timeout.Milliseconds()), + } + + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + reqBytes = append(reqBytes, '\n') + + if _, err := conn.Write(reqBytes); err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + reader := bufio.NewReader(conn) + respLine, err := reader.ReadBytes('\n') + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var resp playwrightDaemonResponse + if err := json.Unmarshal(respLine, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if resp.ID != reqID { + return nil, fmt.Errorf("response ID mismatch: expected %s, got %s", reqID, resp.ID) + } + + return &resp, nil +} + func (s *ApiService) ExecutePlaywrightCode(ctx context.Context, request oapi.ExecutePlaywrightCodeRequestObject) (oapi.ExecutePlaywrightCodeResponseObject, error) { - // Serialize Playwright execution - only one execution at a time s.playwrightMu.Lock() defer s.playwrightMu.Unlock() log := logger.FromContext(ctx) - // Validate request if request.Body == nil || request.Body.Code == "" { return oapi.ExecutePlaywrightCode400JSONResponse{ BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{ @@ -29,107 +144,42 @@ func (s *ApiService) ExecutePlaywrightCode(ctx context.Context, request oapi.Exe }, nil } - // Determine timeout (default to 60 seconds) timeout := 60 * time.Second if request.Body.TimeoutSec != nil && *request.Body.TimeoutSec > 0 { timeout = time.Duration(*request.Body.TimeoutSec) * time.Second } - // Create a temporary file for the user code - tmpFile, err := os.CreateTemp("", "playwright-code-*.ts") - if err != nil { - log.Error("failed to create temp file", "error", err) + if err := s.ensurePlaywrightDaemon(ctx); err != nil { + log.Error("failed to ensure playwright daemon", "error", err) return oapi.ExecutePlaywrightCode500JSONResponse{ InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{ - Message: fmt.Sprintf("failed to create temp file: %v", err), + Message: fmt.Sprintf("failed to start playwright daemon: %v", err), }, }, nil } - tmpFilePath := tmpFile.Name() - defer os.Remove(tmpFilePath) // Clean up the temp file - - // Write the user code to the temp file - if _, err := tmpFile.WriteString(request.Body.Code); err != nil { - tmpFile.Close() - log.Error("failed to write code to temp file", "error", err) - return oapi.ExecutePlaywrightCode500JSONResponse{ - InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{ - Message: fmt.Sprintf("failed to write code to temp file: %v", err), - }, - }, nil - } - tmpFile.Close() - - // Create context with timeout - execCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - // Execute the Playwright code via the executor script - cmd := exec.CommandContext(execCtx, "tsx", "/usr/local/lib/playwright-executor.ts", tmpFilePath) - - output, err := cmd.CombinedOutput() + resp, err := s.executeViaUnixSocket(ctx, request.Body.Code, timeout) if err != nil { - if execCtx.Err() == context.DeadlineExceeded { - log.Error("playwright execution timed out", "timeout", timeout) - success := false - errorMsg := fmt.Sprintf("execution timed out after %v", timeout) - return oapi.ExecutePlaywrightCode200JSONResponse{ - Success: success, - Error: &errorMsg, - }, nil - } - log.Error("playwright execution failed", "error", err) - - // Try to parse the error output as JSON - var result struct { - Success bool `json:"success"` - Result interface{} `json:"result,omitempty"` - Error string `json:"error,omitempty"` - Stack string `json:"stack,omitempty"` - } - if jsonErr := json.Unmarshal(output, &result); jsonErr == nil { - success := result.Success - errorMsg := result.Error - stderr := string(output) - return oapi.ExecutePlaywrightCode200JSONResponse{ - Success: success, - Error: &errorMsg, - Stderr: &stderr, - }, nil - } - - // If we can't parse the output, return a generic error - success := false errorMsg := fmt.Sprintf("execution failed: %v", err) - stderr := string(output) return oapi.ExecutePlaywrightCode200JSONResponse{ - Success: success, + Success: false, Error: &errorMsg, - Stderr: &stderr, }, nil } - // Parse successful output - var result struct { - Success bool `json:"success"` - Result interface{} `json:"result,omitempty"` - } - if err := json.Unmarshal(output, &result); err != nil { - log.Error("failed to parse playwright output", "error", err) - success := false - errorMsg := fmt.Sprintf("failed to parse output: %v", err) - stdout := string(output) + if !resp.Success { + errorMsg := resp.Error + stderr := resp.Stack return oapi.ExecutePlaywrightCode200JSONResponse{ - Success: success, + Success: false, Error: &errorMsg, - Stdout: &stdout, + Stderr: &stderr, }, nil } return oapi.ExecutePlaywrightCode200JSONResponse{ - Success: result.Success, - Result: &result.Result, + Success: true, + Result: &resp.Result, }, nil } diff --git a/server/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go index acbdb633..8bc813f2 100644 --- a/server/e2e/e2e_chromium_test.go +++ b/server/e2e/e2e_chromium_test.go @@ -731,86 +731,6 @@ func getXvfbResolution(ctx context.Context) (width, height int, err error) { return 0, 0, fmt.Errorf("Xvfb process not found in ps aux output") } -func TestPlaywrightExecuteAPI(t *testing.T) { - image := headlessImage - name := containerName + "-playwright-api" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) - - if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) - } - - // Clean slate - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) - defer cancel() - - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) - - client, err := apiClient() - require.NoError(t, err) - - // Test simple Playwright script that navigates to a page and returns the title - playwrightCode := ` - await page.goto('https://example.com'); - const title = await page.title(); - return title; - ` - - logger.Info("[test]", "action", "executing playwright code") - req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ - Code: playwrightCode, - } - - rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) - require.NoError(t, err, "playwright execute request error: %v", err) - require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for playwright execute: %s body=%s", rsp.Status(), string(rsp.Body)) - require.NotNil(t, rsp.JSON200, "expected JSON200 response, got nil") - - // Log the full response for debugging - if !rsp.JSON200.Success { - var errorMsg string - if rsp.JSON200.Error != nil { - errorMsg = *rsp.JSON200.Error - } - var stdout, stderr string - if rsp.JSON200.Stdout != nil { - stdout = *rsp.JSON200.Stdout - } - if rsp.JSON200.Stderr != nil { - stderr = *rsp.JSON200.Stderr - } - logger.Error("[test]", "error", errorMsg, "stdout", stdout, "stderr", stderr) - } - - require.True(t, rsp.JSON200.Success, "expected success=true, got success=false. Error: %s", func() string { - if rsp.JSON200.Error != nil { - return *rsp.JSON200.Error - } - return "nil" - }()) - require.NotNil(t, rsp.JSON200.Result, "expected result to be non-nil") - - // Verify the result contains "Example Domain" (the title of example.com) - resultBytes, err := json.Marshal(rsp.JSON200.Result) - require.NoError(t, err, "failed to marshal result: %v", err) - resultStr := string(resultBytes) - logger.Info("[test]", "result", resultStr) - require.Contains(t, resultStr, "Example Domain", "expected result to contain 'Example Domain'") - - logger.Info("[test]", "result", "playwright execute API test passed") -} - // TestCDPTargetCreation tests that headless browsers can create new targets via CDP. func TestCDPTargetCreation(t *testing.T) { image := headlessImage diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go new file mode 100644 index 00000000..76c3645a --- /dev/null +++ b/server/e2e/e2e_playwright_test.go @@ -0,0 +1,180 @@ +package e2e + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "os/exec" + "testing" + "time" + + logctx "github.com/onkernel/kernel-images/server/lib/logger" + instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestPlaywrightExecuteAPI(t *testing.T) { + image := headlessImage + name := containerName + "-playwright-api" + + logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) + baseCtx := logctx.AddToContext(context.Background(), logger) + + if _, err := exec.LookPath("docker"); err != nil { + require.NoError(t, err, "docker not available: %v", err) + } + + _ = stopContainer(baseCtx, name) + + env := map[string]string{} + + _, exitCh, err := runContainer(baseCtx, image, name, env) + require.NoError(t, err, "failed to start container: %v", err) + defer stopContainer(baseCtx, name) + + ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) + defer cancel() + + require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + + client, err := apiClient() + require.NoError(t, err) + + playwrightCode := ` + await page.goto('https://example.com'); + const title = await page.title(); + return title; + ` + + logger.Info("[test]", "action", "executing playwright code") + req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: playwrightCode, + } + + rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) + require.NoError(t, err, "playwright execute request error: %v", err) + require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status for playwright execute: %s body=%s", rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200, "expected JSON200 response, got nil") + + if !rsp.JSON200.Success { + var errorMsg string + if rsp.JSON200.Error != nil { + errorMsg = *rsp.JSON200.Error + } + var stdout, stderr string + if rsp.JSON200.Stdout != nil { + stdout = *rsp.JSON200.Stdout + } + if rsp.JSON200.Stderr != nil { + stderr = *rsp.JSON200.Stderr + } + logger.Error("[test]", "error", errorMsg, "stdout", stdout, "stderr", stderr) + } + + require.True(t, rsp.JSON200.Success, "expected success=true, got success=false. Error: %s", func() string { + if rsp.JSON200.Error != nil { + return *rsp.JSON200.Error + } + return "nil" + }()) + require.NotNil(t, rsp.JSON200.Result, "expected result to be non-nil") + + resultBytes, err := json.Marshal(rsp.JSON200.Result) + require.NoError(t, err, "failed to marshal result: %v", err) + resultStr := string(resultBytes) + logger.Info("[test]", "result", resultStr) + require.Contains(t, resultStr, "Example Domain", "expected result to contain 'Example Domain'") + + logger.Info("[test]", "result", "playwright execute API test passed") +} + +// TestPlaywrightDaemonRecovery tests that the playwright daemon recovers after chromium is restarted. +// The daemon maintains a warm CDP connection, but when chromium restarts, that connection breaks. +// The daemon should detect the disconnection and reconnect on the next request. +func TestPlaywrightDaemonRecovery(t *testing.T) { + image := headlessImage + name := containerName + "-playwright-recovery" + + logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) + baseCtx := logctx.AddToContext(context.Background(), logger) + + if _, err := exec.LookPath("docker"); err != nil { + require.NoError(t, err, "docker not available: %v", err) + } + + _ = stopContainer(baseCtx, name) + + env := map[string]string{} + + _, exitCh, err := runContainer(baseCtx, image, name, env) + require.NoError(t, err, "failed to start container: %v", err) + defer stopContainer(baseCtx, name) + + ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + defer cancel() + + require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready: %v", err) + + client, err := apiClient() + require.NoError(t, err) + + // Helper to execute playwright code and verify success + executeAndVerify := func(description string) { + logger.Info("[test]", "action", description) + + code := `return await page.evaluate(() => navigator.userAgent);` + req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} + + rsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, req) + require.NoError(t, err, "%s: request error: %v", description, err) + require.Equal(t, http.StatusOK, rsp.StatusCode(), "%s: unexpected status: %s body=%s", description, rsp.Status(), string(rsp.Body)) + require.NotNil(t, rsp.JSON200, "%s: expected JSON200 response", description) + + if !rsp.JSON200.Success { + var errorMsg, stderr string + if rsp.JSON200.Error != nil { + errorMsg = *rsp.JSON200.Error + } + if rsp.JSON200.Stderr != nil { + stderr = *rsp.JSON200.Stderr + } + t.Fatalf("%s: execution failed. Error: %s, Stderr: %s", description, errorMsg, stderr) + } + + require.NotNil(t, rsp.JSON200.Result, "%s: expected result to be non-nil", description) + logger.Info("[test]", "result", "success", "description", description) + } + + // Step 1: Execute playwright code to start the daemon and establish CDP connection + executeAndVerify("initial execution (starts daemon)") + + // Step 2: Restart chromium via supervisorctl + logger.Info("[test]", "action", "restarting chromium via supervisorctl") + { + args := []string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"} + req := instanceoapi.ProcessExecJSONRequestBody{ + Command: "supervisorctl", + Args: &args, + } + rsp, err := client.ProcessExecWithResponse(ctx, req) + require.NoError(t, err, "supervisorctl restart request error: %v", err) + require.Equal(t, http.StatusOK, rsp.StatusCode(), "supervisorctl restart unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) + + if rsp.JSON200.StdoutB64 != nil { + logger.Info("[test]", "supervisorctl_stdout_b64", *rsp.JSON200.StdoutB64) + } + if rsp.JSON200.StderrB64 != nil { + logger.Info("[test]", "supervisorctl_stderr_b64", *rsp.JSON200.StderrB64) + } + } + + // Step 3: Wait for chromium to be ready again + logger.Info("[test]", "action", "waiting for chromium to be ready after restart") + time.Sleep(2 * time.Second) + + // Step 4: Execute playwright code again - daemon should recover + executeAndVerify("execution after chromium restart (daemon should recover)") + + logger.Info("[test]", "result", "playwright daemon recovery test passed") +} diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts new file mode 100644 index 00000000..ed1e6ed5 --- /dev/null +++ b/server/runtime/playwright-daemon.ts @@ -0,0 +1,266 @@ +/** + * Persistent Playwright Executor Daemon + * + * Listens on a Unix socket for code execution requests, maintains a warm CDP + * connection to the browser, and uses esbuild for TypeScript transformation. + * + * Protocol (newline-delimited JSON): + * Request: { "id": string, "code": string, "timeout_ms"?: number } + * Response: { "id": string, "success": boolean, "result"?: any, "error"?: string, "stack"?: string } + */ + +import { createServer, Socket } from 'net'; +import { unlinkSync, existsSync } from 'fs'; +import { transform } from 'esbuild'; +import { chromium as chromiumPW, Browser } from 'playwright-core'; +import { chromium as chromiumPR } from 'patchright'; + +const SOCKET_PATH = process.env.PLAYWRIGHT_DAEMON_SOCKET || '/tmp/playwright-daemon.sock'; +const CDP_ENDPOINT = process.env.CDP_ENDPOINT || 'ws://127.0.0.1:9222'; +const USE_PATCHRIGHT = process.env.PLAYWRIGHT_ENGINE !== 'playwright-core'; +const RECONNECT_DELAY_MS = 1000; +const MAX_RECONNECT_ATTEMPTS = 10; + +let browser: Browser | null = null; +let connecting = false; +let reconnectAttempts = 0; + +interface ExecuteRequest { + id: string; + code: string; + timeout_ms?: number; +} + +interface ExecuteResponse { + id: string; + success: boolean; + result?: unknown; + error?: string; + stack?: string; +} + +async function transformCode(code: string): Promise { + // Wrap in async function so top-level await/return are valid for esbuild + const wrapped = `async function __userCode__() {\n${code}\n}`; + + const result = await transform(wrapped, { + loader: 'ts', + target: 'es2022', + }); + + // Extract the function body + const transformed = result.code; + const bodyStart = transformed.indexOf('{') + 1; + const bodyEnd = transformed.lastIndexOf('}'); + + if (bodyStart <= 0 || bodyEnd <= bodyStart) { + return code; + } + + return transformed.slice(bodyStart, bodyEnd).trim(); +} + +async function ensureBrowserConnection(): Promise { + if (browser && browser.isConnected()) { + return browser; + } + + if (connecting) { + while (connecting) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + if (browser && browser.isConnected()) { + return browser; + } + } + + connecting = true; + try { + const chromium = USE_PATCHRIGHT ? chromiumPR : chromiumPW; + + if (browser) { + try { + await browser.close(); + } catch { + // Ignore + } + browser = null; + } + + console.error(`[playwright-daemon] Connecting to CDP: ${CDP_ENDPOINT}`); + browser = await chromium.connectOverCDP(CDP_ENDPOINT); + reconnectAttempts = 0; + + browser.on('disconnected', () => { + console.error('[playwright-daemon] Browser disconnected'); + browser = null; + }); + + console.error('[playwright-daemon] CDP connection established'); + return browser; + } finally { + connecting = false; + } +} + +async function executeCode(request: ExecuteRequest): Promise { + const { id, code, timeout_ms = 60000 } = request; + + try { + let jsCode: string; + try { + jsCode = await transformCode(code); + } catch (transformError: any) { + return { + id, + success: false, + error: `TypeScript transform error: ${transformError.message}`, + stack: transformError.stack, + }; + } + + let browserInstance: Browser; + try { + browserInstance = await ensureBrowserConnection(); + } catch (connError: any) { + reconnectAttempts++; + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + return { + id, + success: false, + error: `Failed to connect to browser after ${MAX_RECONNECT_ATTEMPTS} attempts: ${connError.message}`, + }; + } + await new Promise(resolve => setTimeout(resolve, RECONNECT_DELAY_MS)); + try { + browserInstance = await ensureBrowserConnection(); + } catch (retryError: any) { + return { + id, + success: false, + error: `Failed to connect to browser: ${retryError.message}`, + }; + } + } + + const contexts = browserInstance.contexts(); + const context = contexts.length > 0 ? contexts[0] : await browserInstance.newContext(); + const pages = context.pages(); + const page = pages.length > 0 ? pages[0] : await context.newPage(); + + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const userFunction = new AsyncFunction('page', 'context', 'browser', jsCode); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Execution timed out after ${timeout_ms}ms`)), timeout_ms); + }); + + const result = await Promise.race([ + userFunction(page, context, browserInstance), + timeoutPromise, + ]); + + return { + id, + success: true, + result: result !== undefined ? result : null, + }; + } catch (error: any) { + return { + id, + success: false, + error: error.message, + stack: error.stack, + }; + } +} + +function handleConnection(socket: Socket): void { + let buffer = ''; + + socket.on('data', async (data) => { + buffer += data.toString(); + + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + + if (!line.trim()) continue; + + let request: ExecuteRequest; + try { + request = JSON.parse(line); + } catch { + socket.write(JSON.stringify({ id: 'unknown', success: false, error: 'Invalid JSON request' }) + '\n'); + continue; + } + + if (!request.id || typeof request.code !== 'string') { + socket.write(JSON.stringify({ id: request.id || 'unknown', success: false, error: 'Invalid request: missing id or code' }) + '\n'); + continue; + } + + const response = await executeCode(request); + socket.write(JSON.stringify(response) + '\n'); + } + }); + + socket.on('error', (err) => { + console.error('[playwright-daemon] Socket error:', err.message); + }); +} + +async function shutdown(signal: string): Promise { + console.error(`[playwright-daemon] Received ${signal}, shutting down...`); + + if (browser) { + try { + await browser.close(); + } catch { + // Ignore + } + } + + try { + if (existsSync(SOCKET_PATH)) { + unlinkSync(SOCKET_PATH); + } + } catch { + // Ignore + } + + process.exit(0); +} + +async function main(): Promise { + try { + if (existsSync(SOCKET_PATH)) { + unlinkSync(SOCKET_PATH); + } + } catch { + // Ignore + } + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + const server = createServer(handleConnection); + + server.on('error', (err) => { + console.error('[playwright-daemon] Server error:', err); + process.exit(1); + }); + + server.listen(SOCKET_PATH, () => { + console.error(`[playwright-daemon] Listening on ${SOCKET_PATH}`); + ensureBrowserConnection().catch((err) => { + console.error('[playwright-daemon] Initial connection failed:', err.message); + }); + }); +} + +main().catch((err) => { + console.error('[playwright-daemon] Fatal error:', err); + process.exit(1); +}); diff --git a/server/runtime/playwright-executor.ts b/server/runtime/playwright-executor.ts deleted file mode 100644 index 7bb87052..00000000 --- a/server/runtime/playwright-executor.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { readFileSync } from 'fs'; -import { chromium as chromiumPW } from 'playwright-core'; -import { chromium as chromiumPR } from 'patchright'; - -async function main() { - const codeFilePath = process.argv[2]; - - if (!codeFilePath) { - console.error('Usage: tsx playwright-executor.ts '); - process.exit(1); - } - - let userCode: string; - try { - userCode = readFileSync(codeFilePath, 'utf-8'); - } catch (error: any) { - console.error(JSON.stringify({ - success: false, - error: `Failed to read code file: ${error.message}` - })); - process.exit(1); - } - - let browser; - let result; - - try { - const chromium = process.env.PLAYWRIGHT_ENGINE === 'patchright' ? chromiumPR : chromiumPW; - - browser = await chromium.connectOverCDP('ws://127.0.0.1:9222'); - const contexts = browser.contexts(); - const context = contexts.length > 0 ? contexts[0] : await browser.newContext(); - const pages = context.pages(); - const page = pages.length > 0 ? pages[0] : await context.newPage(); - - const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor; - const userFunction = new AsyncFunction('page', 'context', 'browser', userCode); - result = await userFunction(page, context, browser); - - if (result !== undefined) { - console.log(JSON.stringify({ success: true, result: result })); - } else { - console.log(JSON.stringify({ success: true, result: null })); - } - } catch (error: any) { - console.error(JSON.stringify({ - success: false, - error: error.message, - stack: error.stack - })); - process.exit(1); - } finally { - if (browser) { - try { - await browser.close(); - } catch (e) { - // Ignore errors when closing CDP connection - } - } - } -} - -main();