diff --git a/packages/core/examples/p2p-client-example.ts b/packages/core/examples/p2p-client-example.ts new file mode 100644 index 000000000..43015aada --- /dev/null +++ b/packages/core/examples/p2p-client-example.ts @@ -0,0 +1,117 @@ +/** + * Example: Connecting to a Remote Stagehand Server + * + * This example demonstrates how to connect to a remote Stagehand server + * and execute commands that run on the remote machine. + * + * Usage: + * 1. First, start the server in another terminal: + * npx tsx examples/p2p-server-example.ts + * + * 2. Then run this client: + * npx tsx examples/p2p-client-example.ts + */ + +import { Stagehand } from "../dist/index.js"; +import { z } from "zod/v3"; + +async function main() { + const SERVER_URL = process.env.STAGEHAND_SERVER_URL || "http://localhost:3000"; + + console.log("Stagehand P2P Client"); + console.log("=".repeat(60)); + console.log(`Connecting to server at ${SERVER_URL}...`); + + // Create a Stagehand instance + const stagehand = new Stagehand({ + env: "LOCAL", // Required but won't be used since we're connecting to remote + verbose: 1, + }); + + // Connect to the remote server and create a session + await stagehand.connectToRemoteServer(SERVER_URL); + console.log("✓ Connected to remote server\n"); + + // Navigate to a test page first + console.log("=".repeat(60)); + console.log("Navigating to example.com"); + console.log("=".repeat(60)); + try { + // Navigate using the remote API + await stagehand.goto("https://example.com"); + console.log("✓ Navigated to example.com\n"); + } catch (error: any) { + console.error("✗ Navigation error:", error.message); + } + + // All actions now execute on the remote machine + console.log("=".repeat(60)); + console.log("Testing act()"); + console.log("=".repeat(60)); + try { + const actResult = await stagehand.act("scroll to the bottom"); + console.log("✓ Act result:", { + success: actResult.success, + message: actResult.message, + actionsCount: actResult.actions.length, + }); + } catch (error: any) { + console.error("✗ Act error:", error.message); + } + + console.log("\n" + "=".repeat(60)); + console.log("Testing extract()"); + console.log("=".repeat(60)); + try { + const extractResult = await stagehand.extract("extract the page title"); + console.log("✓ Extract result:", extractResult); + } catch (error: any) { + console.error("✗ Extract error:", error.message); + } + + console.log("\n" + "=".repeat(60)); + console.log("Testing observe()"); + console.log("=".repeat(60)); + try { + const observeResult = await stagehand.observe("find all links on the page"); + console.log( + `✓ Observe result: Found ${observeResult.length} actions` + ); + if (observeResult.length > 0) { + console.log(" First action:", { + selector: observeResult[0].selector, + description: observeResult[0].description, + }); + } + } catch (error: any) { + console.error("✗ Observe error:", error.message); + } + + console.log("\n" + "=".repeat(60)); + console.log("Testing extract with schema"); + console.log("=".repeat(60)); + try { + const schema = z.object({ + title: z.string(), + heading: z.string().optional(), + }); + const structuredData = await stagehand.extract( + "extract the page title and main heading", + schema + ); + console.log("✓ Structured data:", structuredData); + } catch (error: any) { + console.error("✗ Structured extract error:", error.message); + } + + console.log("\n" + "=".repeat(60)); + console.log("All tests completed!"); + console.log("=".repeat(60)); + console.log("\nNote: The browser is running on the remote server."); + console.log(" All commands were executed via RPC over HTTP/SSE.\n"); +} + +main().catch((error) => { + console.error("\n❌ Fatal error:", error); + process.exit(1); +}); diff --git a/packages/core/examples/p2p-server-example.ts b/packages/core/examples/p2p-server-example.ts new file mode 100644 index 000000000..69ebb80b7 --- /dev/null +++ b/packages/core/examples/p2p-server-example.ts @@ -0,0 +1,98 @@ +/** + * Example: Running Stagehand as a P2P Server + * + * This example demonstrates how to run Stagehand as an HTTP server + * that other Stagehand instances can connect to and execute commands remotely. + * + * Usage: + * npx tsx examples/p2p-server-example.ts + */ + +import { Stagehand } from "../dist/index.js"; + +async function main() { + console.log("Starting Stagehand P2P Server..."); + + // Check if we should use BROWSERBASE or LOCAL + const useBrowserbase = + process.env.BROWSERBASE_API_KEY && process.env.BROWSERBASE_PROJECT_ID; + + // Create a Stagehand instance + const stagehand = new Stagehand( + useBrowserbase + ? { + env: "BROWSERBASE", + apiKey: process.env.BROWSERBASE_API_KEY, + projectId: process.env.BROWSERBASE_PROJECT_ID, + verbose: 1, + } + : { + env: "LOCAL", + verbose: 1, + localBrowserLaunchOptions: { + headless: false, // Set to false to see the browser + }, + } + ); + + console.log( + `Initializing browser (${useBrowserbase ? "BROWSERBASE" : "LOCAL"})...` + ); + await stagehand.init(); + console.log("✓ Browser initialized"); + + // Create and start the server + console.log("Creating server..."); + const server = stagehand.createServer({ + port: 3000, + host: "127.0.0.1", // Use localhost for testing + }); + + await server.listen(); + console.log(`✓ Server listening at ${server.getUrl()}`); + console.log(` Active sessions: ${server.getActiveSessionCount()}`); + + // Navigate to a starting page + console.log("\nNavigating to google.com..."); + const page = await stagehand.context.awaitActivePage(); + await page.goto("https://google.com"); + console.log("✓ Page loaded"); + + // The server can also use Stagehand locally while serving remote requests + console.log("\nTesting local execution..."); + const result = await stagehand.act("scroll down"); + console.log("✓ Local action completed:", result.success ? "success" : "failed"); + + // Keep the server running + console.log("\n" + "=".repeat(60)); + console.log("Server is ready!"); + console.log("=".repeat(60)); + console.log("\nTo connect from another terminal, run:"); + console.log(" npx tsx examples/p2p-client-example.ts"); + console.log("\nOr from code:"); + console.log(` stagehand.connectToRemoteServer('${server.getUrl()}')`); + console.log("\nPress Ctrl+C to stop the server"); + console.log("=".repeat(60)); + + // Handle graceful shutdown + process.on("SIGINT", async () => { + console.log("\n\nShutting down gracefully..."); + try { + await server.close(); + await stagehand.close(); + console.log("✓ Server closed"); + process.exit(0); + } catch (error) { + console.error("Error during shutdown:", error); + process.exit(1); + } + }); + + // Keep the process alive + await new Promise(() => {}); +} + +main().catch((error) => { + console.error("\n❌ Fatal error:", error); + process.exit(1); +}); diff --git a/packages/core/examples/python-client-example.py b/packages/core/examples/python-client-example.py new file mode 100644 index 000000000..099cb06e2 --- /dev/null +++ b/packages/core/examples/python-client-example.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Example: Using Stagehand Python SDK with Remote Server + +This example demonstrates how to use the Python SDK to connect to a +Stagehand server and execute browser automation tasks. + +Usage: + 1. First, start the Node.js server in another terminal: + npx tsx examples/p2p-server-example.ts + + 2. Install the Python dependencies: + pip install httpx httpx-sse + + 3. Then run this Python client: + python examples/python-client-example.py +""" + +import asyncio +import os +from stagehand import Stagehand + + +async def main(): + server_url = os.getenv("STAGEHAND_SERVER_URL", "http://localhost:3000") + + print("Stagehand Python Client") + print("=" * 60) + print(f"Connecting to server at {server_url}...") + + # Create Stagehand instance + stagehand = Stagehand( + server_url=server_url, + verbose=1, + ) + + try: + # Connect to the remote server and create a session + await stagehand.init() + print("✓ Connected to remote server\n") + + # Navigate to a test page + print("=" * 60) + print("Navigating to example.com") + print("=" * 60) + await stagehand.goto("https://example.com") + print("✓ Navigated to example.com\n") + + # Test act() + print("=" * 60) + print("Testing act()") + print("=" * 60) + try: + act_result = await stagehand.act("scroll to the bottom") + print(f"✓ Act result: success={act_result.success}, " + f"message={act_result.message}, " + f"actions={len(act_result.actions)}") + except Exception as e: + print(f"✗ Act error: {e}") + + # Test extract() + print("\n" + "=" * 60) + print("Testing extract()") + print("=" * 60) + try: + extract_result = await stagehand.extract("extract the page title") + print(f"✓ Extract result: {extract_result}") + except Exception as e: + print(f"✗ Extract error: {e}") + + # Test observe() + print("\n" + "=" * 60) + print("Testing observe()") + print("=" * 60) + try: + observe_result = await stagehand.observe("find all links on the page") + print(f"✓ Observe result: Found {len(observe_result)} actions") + if observe_result: + first_action = observe_result[0] + print(f" First action: selector={first_action.selector}, " + f"description={first_action.description}") + except Exception as e: + print(f"✗ Observe error: {e}") + + # Test extract with schema + print("\n" + "=" * 60) + print("Testing extract with schema") + print("=" * 60) + try: + schema = { + "type": "object", + "properties": { + "title": {"type": "string"}, + "heading": {"type": "string"} + } + } + structured_data = await stagehand.extract( + instruction="extract the page title and main heading", + schema=schema + ) + print(f"✓ Structured data: {structured_data}") + except Exception as e: + print(f"✗ Structured extract error: {e}") + + print("\n" + "=" * 60) + print("All tests completed!") + print("=" * 60) + print("\nNote: The browser is running on the remote Node.js server.") + print(" All commands were executed via RPC over HTTP/SSE.\n") + + finally: + await stagehand.close() + + +# Alternative example using context manager +async def context_manager_example(): + """Example using Python's async context manager""" + async with Stagehand(server_url="http://localhost:3000", verbose=1) as stagehand: + await stagehand.goto("https://example.com") + data = await stagehand.extract("extract the page title") + print(f"Page title: {data}") + + +if __name__ == "__main__": + asyncio.run(main()) + # Or use the context manager version: + # asyncio.run(context_manager_example()) diff --git a/packages/core/examples/stagehand.py b/packages/core/examples/stagehand.py new file mode 100644 index 000000000..346cbe43e --- /dev/null +++ b/packages/core/examples/stagehand.py @@ -0,0 +1,447 @@ +""" +Stagehand Python SDK + +A lightweight Python client for the Stagehand browser automation framework. +Connects to a remote Stagehand server (Node.js) and executes browser automation tasks. + +Dependencies: + pip install httpx + +Usage: + from stagehand import Stagehand + + async def main(): + stagehand = Stagehand(server_url="http://localhost:3000") + await stagehand.init() + + await stagehand.goto("https://example.com") + result = await stagehand.act("click the login button") + data = await stagehand.extract("extract the page title") + + await stagehand.close() +""" + +import json +from typing import Any, Dict, List, Optional, Union +import httpx + + +class StagehandError(Exception): + """Base exception for Stagehand errors""" + pass + + +class StagehandAPIError(StagehandError): + """API-level errors from the Stagehand server""" + pass + + +class StagehandConnectionError(StagehandError): + """Connection errors when communicating with the server""" + pass + + +class Action: + """Represents a browser action returned by observe()""" + + def __init__(self, data: Dict[str, Any]): + self.selector = data.get("selector") + self.description = data.get("description") + self.backend_node_id = data.get("backendNodeId") + self.method = data.get("method") + self.arguments = data.get("arguments", []) + self._raw = data + + def __repr__(self): + return f"Action(selector={self.selector!r}, description={self.description!r})" + + def to_dict(self) -> Dict[str, Any]: + """Convert back to dict for sending to API""" + return self._raw + + +class ActResult: + """Result from act() method""" + + def __init__(self, data: Dict[str, Any]): + self.success = data.get("success", False) + self.message = data.get("message", "") + self.actions = [Action(a) for a in data.get("actions", [])] + self._raw = data + + def __repr__(self): + return f"ActResult(success={self.success}, message={self.message!r})" + + +class Stagehand: + """ + Main Stagehand client for browser automation. + + Connects to a remote Stagehand server and provides methods for browser automation: + - act: Execute actions on the page + - extract: Extract data from the page + - observe: Observe possible actions on the page + - goto: Navigate to a URL + """ + + def __init__( + self, + server_url: str = "http://localhost:3000", + verbose: int = 0, + timeout: float = 120.0, + ): + """ + Initialize the Stagehand client. + + Args: + server_url: URL of the Stagehand server (default: http://localhost:3000) + verbose: Verbosity level 0-2 (default: 0) + timeout: Request timeout in seconds (default: 120) + """ + self.server_url = server_url.rstrip("/") + self.verbose = verbose + self.timeout = timeout + self.session_id: Optional[str] = None + self._client = httpx.AsyncClient(timeout=timeout) + + async def init(self, **options) -> None: + """ + Initialize a browser session on the remote server. + + Args: + **options: Additional options to pass to the server (e.g., model, verbose, etc.) + If env is not specified, defaults to "LOCAL" + """ + if self.session_id: + raise StagehandError("Already initialized. Call close() first.") + + # Default config for server-side browser session + session_config = { + "env": "LOCAL", + "verbose": self.verbose, + **options + } + + try: + response = await self._client.post( + f"{self.server_url}/v1/sessions/start", + json=session_config, + ) + response.raise_for_status() + data = response.json() + + self.session_id = data.get("sessionId") + if not self.session_id: + raise StagehandAPIError("Server did not return a sessionId") + + if self.verbose > 0: + print(f"✓ Initialized session: {self.session_id}") + + except httpx.HTTPError as e: + raise StagehandConnectionError(f"Failed to connect to server: {e}") + + async def goto( + self, + url: str, + options: Optional[Dict[str, Any]] = None, + frame_id: Optional[str] = None, + ) -> Any: + """ + Navigate to a URL. + + Args: + url: The URL to navigate to + options: Navigation options (waitUntil, timeout, etc.) + frame_id: Optional frame ID to navigate + + Returns: + Navigation response + """ + return await self._execute( + method="navigate", + args={ + "url": url, + "options": options, + "frameId": frame_id, + } + ) + + async def act( + self, + instruction: Union[str, Action], + options: Optional[Dict[str, Any]] = None, + frame_id: Optional[str] = None, + ) -> ActResult: + """ + Execute an action on the page. + + Args: + instruction: Natural language instruction or Action object + options: Additional options (model, variables, timeout, etc.) + frame_id: Optional frame ID to act on + + Returns: + ActResult with success status and executed actions + """ + input_data = instruction.to_dict() if isinstance(instruction, Action) else instruction + + # Build request matching server schema + request_data = {"input": input_data} + if options is not None: + request_data["options"] = options + if frame_id is not None: + request_data["frameId"] = frame_id + + result = await self._execute(method="act", args=request_data) + + return ActResult(result) + + async def extract( + self, + instruction: Optional[str] = None, + schema: Optional[Dict[str, Any]] = None, + options: Optional[Dict[str, Any]] = None, + frame_id: Optional[str] = None, + ) -> Any: + """ + Extract data from the page. + + Args: + instruction: Natural language instruction for what to extract + schema: JSON schema defining the expected output structure + options: Additional options (model, selector, timeout, etc.) + frame_id: Optional frame ID to extract from + + Returns: + Extracted data matching the schema (if provided) or default extraction + """ + # Build request matching server schema + request_data = {} + if instruction is not None: + request_data["instruction"] = instruction + if schema is not None: + request_data["schema"] = schema + if options is not None: + request_data["options"] = options + if frame_id is not None: + request_data["frameId"] = frame_id + + return await self._execute(method="extract", args=request_data) + + async def observe( + self, + instruction: Optional[str] = None, + options: Optional[Dict[str, Any]] = None, + frame_id: Optional[str] = None, + ) -> List[Action]: + """ + Observe possible actions on the page. + + Args: + instruction: Natural language instruction for what to observe + options: Additional options (model, selector, timeout, etc.) + frame_id: Optional frame ID to observe + + Returns: + List of Action objects representing possible actions + """ + # Build request matching server schema + request_data = {} + if instruction is not None: + request_data["instruction"] = instruction + if options is not None: + request_data["options"] = options + if frame_id is not None: + request_data["frameId"] = frame_id + + result = await self._execute(method="observe", args=request_data) + + return [Action(action) for action in result] + + async def agent_execute( + self, + instruction: str, + agent_config: Optional[Dict[str, Any]] = None, + execute_options: Optional[Dict[str, Any]] = None, + frame_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Execute an agent task. + + Args: + instruction: The task instruction for the agent + agent_config: Agent configuration (model, systemPrompt, etc.) + execute_options: Execution options (maxSteps, highlightCursor, etc.) + frame_id: Optional frame ID to execute in + + Returns: + Agent execution result + """ + config = agent_config or {} + exec_opts = execute_options or {} + exec_opts["instruction"] = instruction + + return await self._execute( + method="agentExecute", + args={ + "agentConfig": config, + "executeOptions": exec_opts, + "frameId": frame_id, + } + ) + + async def close(self) -> None: + """Close the session and cleanup resources.""" + if self.session_id: + try: + await self._client.post( + f"{self.server_url}/v1/sessions/{self.session_id}/end" + ) + if self.verbose > 0: + print(f"✓ Closed session: {self.session_id}") + except Exception as e: + if self.verbose > 0: + print(f"Warning: Failed to close session: {e}") + finally: + self.session_id = None + + await self._client.aclose() + + async def _execute(self, method: str, args: Dict[str, Any]) -> Any: + """ + Execute a method on the remote server using SSE streaming. + + Args: + method: The method name (act, extract, observe, navigate, agentExecute) + args: Arguments to pass to the method + + Returns: + The result from the server + """ + if not self.session_id: + raise StagehandError("Not initialized. Call init() first.") + + url = f"{self.server_url}/v1/sessions/{self.session_id}/{method}" + + # Create a new client for each request with no connection pooling + limits = httpx.Limits(max_keepalive_connections=0, max_connections=1) + async with httpx.AsyncClient(timeout=self.timeout, limits=limits) as client: + try: + async with client.stream( + "POST", + url, + json=args, + headers={"x-stream-response": "true"}, + ) as response: + response.raise_for_status() + + result = None + + async for line in response.aiter_lines(): + if not line.strip() or not line.startswith("data: "): + continue + + # Parse SSE data + data_str = line[6:] # Remove "data: " prefix + try: + event = json.loads(data_str) + except json.JSONDecodeError: + continue + + event_type = event.get("type") + event_data = event.get("data", {}) + + if event_type == "log": + # Handle log events + if self.verbose > 0: + category = event_data.get("category", "") + message = event_data.get("message", "") + level = event_data.get("level", 0) + if level <= self.verbose: + print(f"[{category}] {message}") + + elif event_type == "system": + # System events contain the result + status = event_data.get("status") + if "result" in event_data: + result = event_data["result"] + elif "error" in event_data: + raise StagehandAPIError(event_data["error"]) + + # Break after receiving finished status + if status == "finished": + break + + if result is None: + raise StagehandAPIError("No result received from server") + + return result + + except httpx.HTTPStatusError as e: + error_msg = f"HTTP {e.response.status_code}" + try: + error_text = await e.response.aread() + error_msg += f": {error_text.decode()}" + except Exception: + pass + raise StagehandAPIError(error_msg) + except httpx.HTTPError as e: + raise StagehandConnectionError(f"Connection error: {e}") + + async def __aenter__(self): + """Context manager entry""" + await self.init() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Context manager exit""" + await self.close() + + +# Example usage +if __name__ == "__main__": + import asyncio + + async def example(): + # Create and initialize Stagehand client + stagehand = Stagehand( + server_url="http://localhost:3000", + verbose=1, + ) + + try: + await stagehand.init() + + # Navigate to a page + print("\n=== Navigating to example.com ===") + await stagehand.goto("https://example.com") + + # Extract data + print("\n=== Extracting page title ===") + data = await stagehand.extract("extract the page title") + print(f"Extracted: {data}") + + # Observe actions + print("\n=== Observing actions ===") + actions = await stagehand.observe("find all links on the page") + print(f"Found {len(actions)} actions") + if actions: + print(f"First action: {actions[0]}") + + # Execute an action + print("\n=== Executing action ===") + result = await stagehand.act("scroll to the bottom") + print(f"Result: {result}") + + finally: + await stagehand.close() + + # Alternative: using context manager + async def example_with_context_manager(): + async with Stagehand(server_url="http://localhost:3000") as stagehand: + await stagehand.goto("https://example.com") + data = await stagehand.extract("extract the page title") + print(data) + + # Run the example + asyncio.run(example()) diff --git a/packages/core/lib/inference.ts b/packages/core/lib/inference.ts index 9b843e043..84ccf100c 100644 --- a/packages/core/lib/inference.ts +++ b/packages/core/lib/inference.ts @@ -12,6 +12,8 @@ import { } from "./prompt"; import { appendSummary, writeTimestampedTxtFile } from "./inferenceLogUtils"; import type { InferStagehandSchema, StagehandZodObject } from "./v3/zodCompat"; +import { createChatCompletionViaEventBus } from "./v3/llm/llmEventBridge"; +import type { StagehandEventBus } from "./v3/eventBus"; // Re-export for backward compatibility export type { LLMParsedResponse, LLMUsage } from "./v3/llm/LLMClient"; @@ -21,6 +23,7 @@ export async function extract({ domElements, schema, llmClient, + eventBus, logger, userProvidedInstructions, logInferenceToFile = false, @@ -29,6 +32,7 @@ export async function extract({ domElements: string; schema: T; llmClient: LLMClient; + eventBus: StagehandEventBus; userProvidedInstructions?: string; logger: (message: LogLine) => void; logInferenceToFile?: boolean; @@ -74,7 +78,7 @@ export async function extract({ const extractStartTime = Date.now(); const extractionResponse = - await llmClient.createChatCompletion({ + await createChatCompletionViaEventBus(eventBus, { options: { messages: extractCallMessages, response_model: { @@ -139,7 +143,7 @@ export async function extract({ const metadataStartTime = Date.now(); const metadataResponse = - await llmClient.createChatCompletion({ + await createChatCompletionViaEventBus(eventBus, { options: { messages: metadataCallMessages, response_model: { @@ -224,6 +228,7 @@ export async function observe({ instruction, domElements, llmClient, + eventBus, userProvidedInstructions, logger, logInferenceToFile = false, @@ -231,6 +236,7 @@ export async function observe({ instruction: string; domElements: string; llmClient: LLMClient; + eventBus: StagehandEventBus; userProvidedInstructions?: string; logger: (message: LogLine) => void; logInferenceToFile?: boolean; @@ -291,20 +297,23 @@ export async function observe({ } const start = Date.now(); - const rawResponse = await llmClient.createChatCompletion({ - options: { - messages, - response_model: { - schema: observeSchema, - name: "Observation", + const rawResponse = await createChatCompletionViaEventBus( + eventBus, + { + options: { + messages, + response_model: { + schema: observeSchema, + name: "Observation", + }, + temperature: isGPT5 ? 1 : 0.1, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, }, - temperature: isGPT5 ? 1 : 0.1, - top_p: 1, - frequency_penalty: 0, - presence_penalty: 0, + logger, }, - logger, - }); + ); const end = Date.now(); const usageTimeMs = end - start; @@ -364,6 +373,7 @@ export async function act({ instruction, domElements, llmClient, + eventBus, userProvidedInstructions, logger, logInferenceToFile = false, @@ -371,6 +381,7 @@ export async function act({ instruction: string; domElements: string; llmClient: LLMClient; + eventBus: StagehandEventBus; userProvidedInstructions?: string; logger: (message: LogLine) => void; logInferenceToFile?: boolean; diff --git a/packages/core/lib/v3/api.ts b/packages/core/lib/v3/api.ts index e873db32b..5024e63ae 100644 --- a/packages/core/lib/v3/api.ts +++ b/packages/core/lib/v3/api.ts @@ -54,17 +54,36 @@ interface ReplayMetricsResponse { } export class StagehandAPIClient { - private apiKey: string; - private projectId: string; + private apiKey?: string; + private projectId?: string; private sessionId?: string; - private modelApiKey: string; + private modelApiKey?: string; + private baseUrl: string; private logger: (message: LogLine) => void; private fetchWithCookies; - constructor({ apiKey, projectId, logger }: StagehandAPIConstructorParams) { + constructor({ + apiKey, + projectId, + baseUrl, + logger, + }: StagehandAPIConstructorParams) { this.apiKey = apiKey; this.projectId = projectId; + this.baseUrl = + baseUrl || + process.env.STAGEHAND_API_URL || + "https://api.stagehand.browserbase.com/v1"; this.logger = logger; + + // Validate: if using cloud API, apiKey and projectId are required + if (!baseUrl && (!apiKey || !projectId)) { + throw new StagehandAPIError( + "apiKey and projectId are required when using the cloud API. " + + "Provide a baseUrl to connect to a local Stagehand server instead.", + ); + } + // Create a single cookie jar instance that will persist across all requests this.fetchWithCookies = makeFetchCookie(fetch); } @@ -469,30 +488,38 @@ export class StagehandAPIClient { private async request(path: string, options: RequestInit): Promise { const defaultHeaders: Record = { - "x-bb-api-key": this.apiKey, - "x-bb-project-id": this.projectId, - "x-bb-session-id": this.sessionId, // we want real-time logs, so we stream the response "x-stream-response": "true", - "x-model-api-key": this.modelApiKey, "x-sent-at": new Date().toISOString(), "x-language": "typescript", "x-sdk-version": STAGEHAND_VERSION, }; + + // Only add auth headers if they exist (cloud mode) + if (this.apiKey) { + defaultHeaders["x-bb-api-key"] = this.apiKey; + } + if (this.projectId) { + defaultHeaders["x-bb-project-id"] = this.projectId; + } + if (this.sessionId) { + defaultHeaders["x-bb-session-id"] = this.sessionId; + } + if (this.modelApiKey) { + defaultHeaders["x-model-api-key"] = this.modelApiKey; + } + if (options.method === "POST" && options.body) { defaultHeaders["Content-Type"] = "application/json"; } - const response = await this.fetchWithCookies( - `${process.env.STAGEHAND_API_URL ?? "https://api.stagehand.browserbase.com/v1"}${path}`, - { - ...options, - headers: { - ...defaultHeaders, - ...options.headers, - }, + const response = await this.fetchWithCookies(`${this.baseUrl}${path}`, { + ...options, + headers: { + ...defaultHeaders, + ...options.headers, }, - ); + }); return response; } diff --git a/packages/core/lib/v3/index.ts b/packages/core/lib/v3/index.ts index 8e102cba7..e924587ab 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -2,6 +2,16 @@ export { V3 } from "./v3"; export { V3 as Stagehand } from "./v3"; export * from "./types/public"; + +// Event bus - shared by library and server +export { StagehandEventBus, createEventBus } from "./eventBus"; +export * from "./server/events"; + +// Server exports for P2P functionality +export { StagehandServer } from "./server"; +export type { StagehandServerOptions } from "./server"; +export { SessionManager } from "./server/sessions"; +export type { SessionEntry } from "./server/sessions"; export { AnnotatedScreenshotText, LLMClient } from "./llm/LLMClient"; export { AgentProvider, modelToAgentProviderMap } from "./agent/AgentProvider"; diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts new file mode 100644 index 000000000..7ec174270 --- /dev/null +++ b/packages/core/lib/v3/server/index.ts @@ -0,0 +1,912 @@ +import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import cors from "@fastify/cors"; +import { z } from "zod"; +import { randomUUID } from "crypto"; +import type { + V3Options, + ActOptions, + ActResult, + ExtractResult, + ExtractOptions, + ObserveOptions, + Action, + AgentResult, +} from "../types/public"; +import type { StagehandZodSchema } from "../zodCompat"; +import { SessionManager } from "./sessions"; +import { createStreamingResponse } from "./stream"; +import { + actSchemaV3, + extractSchemaV3, + observeSchemaV3, + agentExecuteSchemaV3, + navigateSchemaV3, +} from "./schemas"; +import type { + StagehandServerEventMap, + StagehandRequestReceivedEvent, + StagehandRequestCompletedEvent, +} from "./events"; +import { StagehandEventBus, createEventBus } from "../eventBus"; + +// Re-export event types for consumers +export * from "./events"; + +export interface StagehandServerOptions { + port?: number; + host?: string; + sessionTTL?: number; + /** Optional: shared event bus instance. If not provided, a new one will be created. */ + eventBus?: StagehandEventBus; +} + +/** + * StagehandServer - Embedded API server for peer-to-peer Stagehand communication + * + * This server implements the same API as the cloud Stagehand API, allowing + * remote Stagehand instances to connect and execute actions on this machine. + * + * Uses a shared event bus to allow cloud servers to hook into lifecycle events. + */ +export class StagehandServer { + private app: FastifyInstance; + private sessionManager: SessionManager; + private port: number; + private host: string; + private isListening: boolean = false; + private eventBus: StagehandEventBus; + + constructor(options: StagehandServerOptions) { + this.eventBus = options.eventBus || createEventBus(); + this.port = options.port || 3000; + this.host = options.host || "0.0.0.0"; + this.sessionManager = new SessionManager(options.sessionTTL, this.eventBus); + this.app = Fastify({ + logger: false, // Disable Fastify's built-in logger for cleaner output + }); + + this.setupMiddleware(); + this.setupRoutes(); + } + + /** + * Emit an event and wait for all async listeners to complete + */ + private async emitAsync( + event: K, + data: StagehandServerEventMap[K], + ): Promise { + await this.eventBus.emitAsync(event, data); + } + + private setupMiddleware(): void { + // CORS support + this.app.register(cors, { + origin: "*", + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: "*", + credentials: true, + }); + } + + private setupRoutes(): void { + // Health check + this.app.get("/health", async () => { + return { status: "ok", sessions: this.sessionManager.getActiveSessions().length }; + }); + + // Start session - creates a new V3 instance + this.app.post("/v1/sessions/start", async (request, reply) => { + return this.handleStartSession(request, reply); + }); + + // Act endpoint + this.app.post<{ Params: { id: string } }>( + "/v1/sessions/:id/act", + async (request, reply) => { + return this.handleAct(request, reply); + }, + ); + + // Extract endpoint + this.app.post<{ Params: { id: string } }>( + "/v1/sessions/:id/extract", + async (request, reply) => { + return this.handleExtract(request, reply); + }, + ); + + // Observe endpoint + this.app.post<{ Params: { id: string } }>( + "/v1/sessions/:id/observe", + async (request, reply) => { + return this.handleObserve(request, reply); + }, + ); + + // Agent execute endpoint + this.app.post<{ Params: { id: string } }>( + "/v1/sessions/:id/agentExecute", + async (request, reply) => { + return this.handleAgentExecute(request, reply); + }, + ); + + // Navigate endpoint - navigate to URL + this.app.post<{ Params: { id: string } }>( + "/v1/sessions/:id/navigate", + async (request, reply) => { + return this.handleNavigate(request, reply); + }, + ); + + // End session + this.app.post<{ Params: { id: string } }>( + "/v1/sessions/:id/end", + async (request, reply) => { + return this.handleEndSession(request, reply); + }, + ); + } + + /** + * Handle /sessions/start - Create new session + */ + private async handleStartSession( + request: FastifyRequest, + reply: FastifyReply, + ): Promise { + const requestId = randomUUID(); + const startTime = Date.now(); + + try { + // Parse V3Options from request body + const config = request.body as V3Options; + + // Emit request received event + await this.emitAsync("StagehandRequestReceived", { + type: "StagehandRequestReceived", + timestamp: new Date(), + requestId, + sessionId: "", // No session yet + method: "POST", + path: "/v1/sessions/start", + headers: { + "x-stream-response": request.headers["x-stream-response"] === "true", + "x-bb-api-key": request.headers["x-bb-api-key"] as string | undefined, + "x-model-api-key": request.headers["x-model-api-key"] as string | undefined, + "x-sdk-version": request.headers["x-sdk-version"] as string | undefined, + "x-language": request.headers["x-language"] as string | undefined, + "x-sent-at": request.headers["x-sent-at"] as string | undefined, + }, + bodySize: JSON.stringify(request.body).length, + }); + + // Create session (will emit StagehandSessionCreated) + const sessionId = this.sessionManager.createSession(config); + + // Emit request completed event + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 200, + durationMs: Date.now() - startTime, + }); + + reply.status(200).send({ + sessionId, + available: true, + }); + } catch (error) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + requestId, + sessionId: "", + statusCode: 500, + durationMs: Date.now() - startTime, + }); + + reply.status(500).send({ + error: error instanceof Error ? error.message : "Failed to create session", + }); + } + } + + /** + * Handle /sessions/:id/act - Execute act command + */ + private async handleAct( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ): Promise { + const { id: sessionId } = request.params; + const requestId = randomUUID(); + const startTime = Date.now(); + + // Emit request received event + await this.emitAsync("StagehandRequestReceived", { + type: "StagehandRequestReceived", + timestamp: new Date(), + sessionId, + requestId, + method: "POST", + path: `/v1/sessions/${sessionId}/act`, + headers: { + "x-stream-response": request.headers["x-stream-response"] === "true", + "x-bb-api-key": request.headers["x-bb-api-key"] as string | undefined, + "x-model-api-key": request.headers["x-model-api-key"] as string | undefined, + "x-sdk-version": request.headers["x-sdk-version"] as string | undefined, + "x-language": request.headers["x-language"] as string | undefined, + "x-sent-at": request.headers["x-sent-at"] as string | undefined, + }, + bodySize: JSON.stringify(request.body).length, + }); + + if (!this.sessionManager.hasSession(sessionId)) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 404, + durationMs: Date.now() - startTime, + }); + return reply.status(404).send({ error: "Session not found" }); + } + + try { + // Validate request body + const data = actSchemaV3.parse(request.body); + + await createStreamingResponse>({ + sessionId, + requestId, + actionType: "act", + sessionManager: this.sessionManager, + request, + reply, + eventBus: this.eventBus, + handler: async (ctx, data) => { + const { stagehand } = ctx; + const { frameId } = data; + + // Get the page + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); + + if (!page) { + throw new Error("Page not found"); + } + + // Build options + const safeOptions: ActOptions = { + model: data.options?.model + ? { + ...data.options.model, + modelName: data.options.model.model ?? "gpt-4o", + } + : undefined, + variables: data.options?.variables, + timeout: data.options?.timeout, + page, + }; + + // Execute act + let result: ActResult; + if (typeof data.input === "string") { + result = await stagehand.act(data.input, safeOptions); + } else { + result = await stagehand.act(data.input as Action, safeOptions); + } + + return { result }; + }, + }); + + // Emit request completed event + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 200, + durationMs: Date.now() - startTime, + }); + } catch (error) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: error instanceof z.ZodError ? 400 : 500, + durationMs: Date.now() - startTime, + }); + + if (error instanceof z.ZodError) { + return reply.status(400).send({ + error: "Invalid request body", + details: error.issues, + }); + } + throw error; + } + } + + /** + * Handle /sessions/:id/extract - Execute extract command + */ + private async handleExtract( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ): Promise { + const { id: sessionId } = request.params; + const requestId = randomUUID(); + const startTime = Date.now(); + + await this.emitAsync("StagehandRequestReceived", { + type: "StagehandRequestReceived", + timestamp: new Date(), + sessionId, + requestId, + method: "POST", + path: `/v1/sessions/${sessionId}/extract`, + headers: { + "x-stream-response": request.headers["x-stream-response"] === "true", + "x-bb-api-key": request.headers["x-bb-api-key"] as string | undefined, + "x-model-api-key": request.headers["x-model-api-key"] as string | undefined, + "x-sdk-version": request.headers["x-sdk-version"] as string | undefined, + "x-language": request.headers["x-language"] as string | undefined, + "x-sent-at": request.headers["x-sent-at"] as string | undefined, + }, + bodySize: JSON.stringify(request.body).length, + }); + + if (!this.sessionManager.hasSession(sessionId)) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 404, + durationMs: Date.now() - startTime, + }); + return reply.status(404).send({ error: "Session not found" }); + } + + try { + const data = extractSchemaV3.parse(request.body); + + await createStreamingResponse>({ + sessionId, + requestId, + actionType: "extract", + sessionManager: this.sessionManager, + request, + reply, + eventBus: this.eventBus, + handler: async (ctx, data) => { + const { stagehand } = ctx; + const { frameId } = data; + + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); + + if (!page) { + throw new Error("Page not found"); + } + + const safeOptions: ExtractOptions = { + model: data.options?.model + ? { + ...data.options.model, + modelName: data.options.model.model ?? "gpt-4o", + } + : undefined, + timeout: data.options?.timeout, + selector: data.options?.selector, + page, + }; + + let result: ExtractResult; + + if (data.instruction) { + if (data.schema) { + // Convert JSON schema to Zod schema + // For simplicity, we'll just pass the data through + // The cloud API does jsonSchemaToZod conversion but that's complex + result = await stagehand.extract(data.instruction, safeOptions); + } else { + result = await stagehand.extract(data.instruction, safeOptions); + } + } else { + result = await stagehand.extract(safeOptions); + } + + return { result }; + }, + }); + + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 200, + durationMs: Date.now() - startTime, + }); + } catch (error) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: error instanceof z.ZodError ? 400 : 500, + durationMs: Date.now() - startTime, + }); + + if (error instanceof z.ZodError) { + return reply.status(400).send({ + error: "Invalid request body", + details: error.issues, + }); + } + throw error; + } + } + + /** + * Handle /sessions/:id/observe - Execute observe command + */ + private async handleObserve( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ): Promise { + const { id: sessionId } = request.params; + const requestId = randomUUID(); + const startTime = Date.now(); + + await this.emitAsync("StagehandRequestReceived", { + type: "StagehandRequestReceived", + timestamp: new Date(), + sessionId, + requestId, + method: "POST", + path: `/v1/sessions/${sessionId}/observe`, + headers: { + "x-stream-response": request.headers["x-stream-response"] === "true", + "x-bb-api-key": request.headers["x-bb-api-key"] as string | undefined, + "x-model-api-key": request.headers["x-model-api-key"] as string | undefined, + "x-sdk-version": request.headers["x-sdk-version"] as string | undefined, + "x-language": request.headers["x-language"] as string | undefined, + "x-sent-at": request.headers["x-sent-at"] as string | undefined, + }, + bodySize: JSON.stringify(request.body).length, + }); + + if (!this.sessionManager.hasSession(sessionId)) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 404, + durationMs: Date.now() - startTime, + }); + return reply.status(404).send({ error: "Session not found" }); + } + + try { + const data = observeSchemaV3.parse(request.body); + + await createStreamingResponse>({ + sessionId, + requestId, + actionType: "observe", + sessionManager: this.sessionManager, + request, + reply, + eventBus: this.eventBus, + handler: async (ctx, data) => { + const { stagehand } = ctx; + const { frameId } = data; + + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); + + if (!page) { + throw new Error("Page not found"); + } + + const safeOptions: ObserveOptions = { + model: + data.options?.model && typeof data.options.model.model === "string" + ? { + ...data.options.model, + modelName: data.options.model.model, + } + : undefined, + timeout: data.options?.timeout, + selector: data.options?.selector, + page, + }; + + let result: Action[]; + + if (data.instruction) { + result = await stagehand.observe(data.instruction, safeOptions); + } else { + result = await stagehand.observe(safeOptions); + } + + return { result }; + }, + }); + + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 200, + durationMs: Date.now() - startTime, + }); + } catch (error) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: error instanceof z.ZodError ? 400 : 500, + durationMs: Date.now() - startTime, + }); + + if (error instanceof z.ZodError) { + return reply.status(400).send({ + error: "Invalid request body", + details: error.issues, + }); + } + throw error; + } + } + + /** + * Handle /sessions/:id/agentExecute - Execute agent command + */ + private async handleAgentExecute( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ): Promise { + const { id: sessionId } = request.params; + const requestId = randomUUID(); + const startTime = Date.now(); + + await this.emitAsync("StagehandRequestReceived", { + type: "StagehandRequestReceived", + timestamp: new Date(), + sessionId, + requestId, + method: "POST", + path: `/v1/sessions/${sessionId}/agentExecute`, + headers: { + "x-stream-response": request.headers["x-stream-response"] === "true", + "x-bb-api-key": request.headers["x-bb-api-key"] as string | undefined, + "x-model-api-key": request.headers["x-model-api-key"] as string | undefined, + "x-sdk-version": request.headers["x-sdk-version"] as string | undefined, + "x-language": request.headers["x-language"] as string | undefined, + "x-sent-at": request.headers["x-sent-at"] as string | undefined, + }, + bodySize: JSON.stringify(request.body).length, + }); + + if (!this.sessionManager.hasSession(sessionId)) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 404, + durationMs: Date.now() - startTime, + }); + return reply.status(404).send({ error: "Session not found" }); + } + + try { + const data = agentExecuteSchemaV3.parse(request.body); + + await createStreamingResponse>({ + sessionId, + requestId, + actionType: "agentExecute", + sessionManager: this.sessionManager, + request, + reply, + eventBus: this.eventBus, + handler: async (ctx, data) => { + const { stagehand } = ctx; + const { agentConfig, executeOptions, frameId } = data; + + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); + + if (!page) { + throw new Error("Page not found"); + } + + const fullExecuteOptions = { + ...executeOptions, + page, + }; + + const result: AgentResult = await stagehand + .agent(agentConfig) + .execute(fullExecuteOptions); + + return { result }; + }, + }); + + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 200, + durationMs: Date.now() - startTime, + }); + } catch (error) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: error instanceof z.ZodError ? 400 : 500, + durationMs: Date.now() - startTime, + }); + + if (error instanceof z.ZodError) { + return reply.status(400).send({ + error: "Invalid request body", + details: error.issues, + }); + } + throw error; + } + } + + /** + * Handle /sessions/:id/navigate - Navigate to URL + */ + private async handleNavigate( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ): Promise { + const { id: sessionId } = request.params; + const requestId = randomUUID(); + const startTime = Date.now(); + + await this.emitAsync("StagehandRequestReceived", { + type: "StagehandRequestReceived", + timestamp: new Date(), + sessionId, + requestId, + method: "POST", + path: `/v1/sessions/${sessionId}/navigate`, + headers: { + "x-stream-response": request.headers["x-stream-response"] === "true", + "x-bb-api-key": request.headers["x-bb-api-key"] as string | undefined, + "x-model-api-key": request.headers["x-model-api-key"] as string | undefined, + "x-sdk-version": request.headers["x-sdk-version"] as string | undefined, + "x-language": request.headers["x-language"] as string | undefined, + "x-sent-at": request.headers["x-sent-at"] as string | undefined, + }, + bodySize: JSON.stringify(request.body).length, + }); + + if (!this.sessionManager.hasSession(sessionId)) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 404, + durationMs: Date.now() - startTime, + }); + return reply.status(404).send({ error: "Session not found" }); + } + + try { + await createStreamingResponse({ + sessionId, + requestId, + actionType: "navigate", + sessionManager: this.sessionManager, + request, + reply, + eventBus: this.eventBus, + handler: async (ctx, data: any) => { + const { stagehand } = ctx; + const { url, options, frameId } = data; + + if (!url) { + throw new Error("url is required"); + } + + // Get the page + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); + + if (!page) { + throw new Error("Page not found"); + } + + // Navigate to the URL + const response = await page.goto(url, options); + + return { result: response }; + }, + }); + + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 200, + durationMs: Date.now() - startTime, + }); + } catch (error) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 500, + durationMs: Date.now() - startTime, + }); + + if (!reply.sent) { + reply.status(500).send({ + error: error instanceof Error ? error.message : "Failed to navigate", + }); + } + } + } + + /** + * Handle /sessions/:id/end - End session and cleanup + */ + private async handleEndSession( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ): Promise { + const { id: sessionId } = request.params; + const requestId = randomUUID(); + const startTime = Date.now(); + + await this.emitAsync("StagehandRequestReceived", { + type: "StagehandRequestReceived", + timestamp: new Date(), + sessionId, + requestId, + method: "POST", + path: `/v1/sessions/${sessionId}/end`, + headers: { + "x-stream-response": request.headers["x-stream-response"] === "true", + "x-bb-api-key": request.headers["x-bb-api-key"] as string | undefined, + "x-model-api-key": request.headers["x-model-api-key"] as string | undefined, + "x-sdk-version": request.headers["x-sdk-version"] as string | undefined, + "x-language": request.headers["x-language"] as string | undefined, + "x-sent-at": request.headers["x-sent-at"] as string | undefined, + }, + bodySize: JSON.stringify(request.body).length, + }); + + try { + await this.sessionManager.endSession(sessionId, "manual"); + + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 200, + durationMs: Date.now() - startTime, + }); + + reply.status(200).send({ success: true }); + } catch (error) { + await this.emitAsync("StagehandRequestCompleted", { + type: "StagehandRequestCompleted", + timestamp: new Date(), + sessionId, + requestId, + statusCode: 500, + durationMs: Date.now() - startTime, + }); + + reply.status(500).send({ + error: error instanceof Error ? error.message : "Failed to end session", + }); + } + } + + /** + * Start the server + */ + async listen(port?: number): Promise { + const listenPort = port || this.port; + + try { + await this.app.listen({ + port: listenPort, + host: this.host, + }); + this.isListening = true; + + // Emit server started event + await this.emitAsync("StagehandServerStarted", { + type: "StagehandServerStarted", + timestamp: new Date(), + port: listenPort, + host: this.host, + }); + + // Emit server ready event + await this.emitAsync("StagehandServerReady", { + type: "StagehandServerReady", + timestamp: new Date(), + }); + + console.log(`Stagehand server listening on http://${this.host}:${listenPort}`); + } catch (error) { + console.error("Failed to start server:", error); + throw error; + } + } + + /** + * Stop the server and cleanup + */ + async close(): Promise { + const graceful = this.isListening; + + // Emit server shutdown event + await this.emitAsync("StagehandServerShutdown", { + type: "StagehandServerShutdown", + timestamp: new Date(), + graceful, + }); + + if (this.isListening) { + await this.app.close(); + this.isListening = false; + } + await this.sessionManager.destroy(); + } + + /** + * Get server URL + */ + getUrl(): string { + if (!this.isListening) { + throw new Error("Server is not listening"); + } + return `http://${this.host}:${this.port}`; + } + + /** + * Get active session count + */ + getActiveSessionCount(): number { + return this.sessionManager.getActiveSessions().length; + } +} diff --git a/packages/core/lib/v3/server/schemas.ts b/packages/core/lib/v3/server/schemas.ts new file mode 100644 index 000000000..dad9882f3 --- /dev/null +++ b/packages/core/lib/v3/server/schemas.ts @@ -0,0 +1,109 @@ +import { z } from "zod"; + +/** + * Shared Zod schemas for Stagehand P2P Server API + * These schemas are used for both runtime validation and OpenAPI generation + */ + +// Zod schemas for V3 API (we only support V3 in the library server) +export const actSchemaV3 = z.object({ + input: z.string().or( + z.object({ + selector: z.string(), + description: z.string(), + backendNodeId: z.number().optional(), + method: z.string().optional(), + arguments: z.array(z.string()).optional(), + }), + ), + options: z + .object({ + model: z + .object({ + provider: z.string().optional(), + model: z.string().optional(), + apiKey: z.string().optional(), + baseURL: z.string().url().optional(), + }) + .optional(), + variables: z.record(z.string(), z.string()).optional(), + timeout: z.number().optional(), + }) + .optional(), + frameId: z.string().optional(), +}); + +export const extractSchemaV3 = z.object({ + instruction: z.string().optional(), + schema: z.record(z.string(), z.unknown()).optional(), + options: z + .object({ + model: z + .object({ + provider: z.string().optional(), + model: z.string().optional(), + apiKey: z.string().optional(), + baseURL: z.string().url().optional(), + }) + .optional(), + timeout: z.number().optional(), + selector: z.string().optional(), + }) + .optional(), + frameId: z.string().optional(), +}); + +export const observeSchemaV3 = z.object({ + instruction: z.string().optional(), + options: z + .object({ + model: z + .object({ + provider: z.string().optional(), + model: z.string().optional(), + apiKey: z.string().optional(), + baseURL: z.string().url().optional(), + }) + .optional(), + timeout: z.number().optional(), + selector: z.string().optional(), + }) + .optional(), + frameId: z.string().optional(), +}); + +export const agentExecuteSchemaV3 = z.object({ + agentConfig: z.object({ + provider: z.enum(["openai", "anthropic", "google"]).optional(), + model: z + .string() + .optional() + .or( + z.object({ + provider: z.enum(["openai", "anthropic", "google"]).optional(), + modelName: z.string(), + apiKey: z.string().optional(), + baseURL: z.string().url().optional(), + }), + ) + .optional(), + systemPrompt: z.string().optional(), + cua: z.boolean().optional(), + }), + executeOptions: z.object({ + instruction: z.string(), + maxSteps: z.number().optional(), + highlightCursor: z.boolean().optional(), + }), + frameId: z.string().optional(), +}); + +export const navigateSchemaV3 = z.object({ + url: z.string(), + options: z + .object({ + waitUntil: z.enum(["load", "domcontentloaded", "networkidle"]).optional(), + }) + .optional(), + frameId: z.string().optional(), +}); diff --git a/packages/core/lib/v3/server/sessions.ts b/packages/core/lib/v3/server/sessions.ts new file mode 100644 index 000000000..ef7fcd973 --- /dev/null +++ b/packages/core/lib/v3/server/sessions.ts @@ -0,0 +1,211 @@ +import type { V3 } from "../v3"; +import type { V3Options, LogLine } from "../types/public"; +import { randomUUID } from "crypto"; +import type { StagehandEventBus } from "../eventBus"; + +export interface SessionEntry { + sessionId: string; + stagehand: V3 | null; + config: V3Options; + loggerRef: { current?: (message: LogLine) => void }; + createdAt: Date; +} + +export class SessionManager { + private sessions: Map; + private cleanupInterval: NodeJS.Timeout | null = null; + private ttlMs: number; + private eventBus: StagehandEventBus; + + constructor(ttlMs: number = 30_000, eventBus: StagehandEventBus) { + this.sessions = new Map(); + this.ttlMs = ttlMs; + this.eventBus = eventBus; + this.startCleanup(); + } + + /** + * Create a new session with the given config + */ + createSession(config: V3Options): string { + const sessionId = randomUUID(); + + this.sessions.set(sessionId, { + sessionId, + stagehand: null, // Will be created on first use + config, + loggerRef: {}, + createdAt: new Date(), + }); + + // Emit session created event (fire and forget - don't await) + void this.eventBus.emitAsync("StagehandSessionCreated", { + type: "StagehandSessionCreated", + timestamp: new Date(), + sessionId, + config, + }); + + return sessionId; + } + + /** + * Get or create a Stagehand instance for a session + */ + async getStagehand( + sessionId: string, + logger?: (message: LogLine) => void, + ): Promise { + const entry = this.sessions.get(sessionId); + + if (!entry) { + throw new Error(`Session not found: ${sessionId}`); + } + + // Emit session resumed event (fire and forget) + void this.eventBus.emitAsync("StagehandSessionResumed", { + type: "StagehandSessionResumed", + timestamp: new Date(), + sessionId, + fromCache: entry.stagehand !== null, + }); + + // Update logger reference if provided + if (logger) { + entry.loggerRef.current = logger; + } + + // If stagehand instance doesn't exist yet, create it + if (!entry.stagehand) { + // Import V3 dynamically to avoid circular dependency + const { V3: V3Class } = await import("../v3"); + + // Create options with dynamic logger + const options: V3Options = { + ...entry.config, + logger: (message: LogLine) => { + // Use the dynamic logger ref so we can update it per request + if (entry.loggerRef.current) { + entry.loggerRef.current(message); + } + // Also call the original logger if it exists + if (entry.config.logger) { + entry.config.logger(message); + } + }, + }; + + entry.stagehand = new V3Class(options); + await entry.stagehand.init(); + + // Emit session initialized event (fire and forget) + void this.eventBus.emitAsync("StagehandSessionInitialized", { + type: "StagehandSessionInitialized", + timestamp: new Date(), + sessionId, + }); + } else if (logger) { + // Update logger for existing instance + entry.loggerRef.current = logger; + } + + return entry.stagehand; + } + + /** + * Get session config without creating Stagehand instance + */ + getSessionConfig(sessionId: string): V3Options | null { + const entry = this.sessions.get(sessionId); + return entry ? entry.config : null; + } + + /** + * Check if a session exists + */ + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + /** + * End a session and cleanup + */ + async endSession(sessionId: string, reason: "manual" | "ttl_expired" | "cache_evicted" | "error" = "manual"): Promise { + const entry = this.sessions.get(sessionId); + + if (!entry) { + return; // Already deleted or never existed + } + + // Close the stagehand instance if it exists + if (entry.stagehand) { + try { + await entry.stagehand.close(); + } catch (error) { + console.error(`Error closing stagehand for session ${sessionId}:`, error); + } + } + + this.sessions.delete(sessionId); + + // Emit session ended event (fire and forget) + void this.eventBus.emitAsync("StagehandSessionEnded", { + type: "StagehandSessionEnded", + timestamp: new Date(), + sessionId, + reason, + }); + } + + /** + * Get all active session IDs + */ + getActiveSessions(): string[] { + return Array.from(this.sessions.keys()); + } + + /** + * Start periodic cleanup of expired sessions + */ + private startCleanup(): void { + // Run cleanup every minute + this.cleanupInterval = setInterval(() => { + this.cleanupExpiredSessions(); + }, 60_000); + } + + /** + * Cleanup sessions that haven't been used in TTL time + */ + private async cleanupExpiredSessions(): Promise { + const now = Date.now(); + const expiredSessions: string[] = []; + + for (const [sessionId, entry] of this.sessions.entries()) { + const age = now - entry.createdAt.getTime(); + if (age > this.ttlMs) { + expiredSessions.push(sessionId); + } + } + + // End all expired sessions + for (const sessionId of expiredSessions) { + console.log(`Cleaning up expired session: ${sessionId}`); + await this.endSession(sessionId, "ttl_expired"); + } + } + + /** + * Stop cleanup interval and close all sessions + */ + async destroy(): Promise { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + // Close all sessions + const sessionIds = Array.from(this.sessions.keys()); + await Promise.all(sessionIds.map((id) => this.endSession(id))); + } +} diff --git a/packages/core/lib/v3/server/stream.ts b/packages/core/lib/v3/server/stream.ts new file mode 100644 index 000000000..8fd45cb6b --- /dev/null +++ b/packages/core/lib/v3/server/stream.ts @@ -0,0 +1,307 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { randomUUID } from "crypto"; +import type { V3 } from "../v3"; +import type { SessionManager } from "./sessions"; +import type { StagehandEventBus } from "../eventBus"; +import type { + StagehandActionStartedEvent, + StagehandActionCompletedEvent, + StagehandActionErroredEvent, + StagehandStreamStartedEvent, + StagehandStreamMessageSentEvent, + StagehandStreamEndedEvent, + StagehandActionProgressEvent, +} from "./events"; + +export interface StreamingHandlerResult { + result: unknown; +} + +export interface StreamingHandlerContext { + stagehand: V3; + sessionId: string; + requestId: string; + request: FastifyRequest; + actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; + eventBus: StagehandEventBus; +} + +export interface StreamingResponseOptions { + sessionId: string; + requestId: string; + actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; + sessionManager: SessionManager; + request: FastifyRequest; + reply: FastifyReply; + eventBus: StagehandEventBus; + handler: (ctx: StreamingHandlerContext, data: T) => Promise; +} + +/** + * Sends an SSE (Server-Sent Events) message to the client + */ +async function sendSSE( + reply: FastifyReply, + data: object, + eventBus: StagehandEventBus, + sessionId: string, + requestId: string, +): Promise { + const message = { + id: randomUUID(), + ...data, + }; + reply.raw.write(`data: ${JSON.stringify(message)}\n\n`); + + // Emit stream message event + await eventBus.emitAsync("StagehandStreamMessageSent", { + type: "StagehandStreamMessageSent", + timestamp: new Date(), + sessionId, + requestId, + messageType: (data as any).type || "unknown", + data: (data as any).data, + }); +} + +/** + * Creates a streaming response handler that sends events via SSE + * Ported from cloud API but without DB/LaunchDarkly dependencies + */ +export async function createStreamingResponse({ + sessionId, + requestId, + actionType, + sessionManager, + request, + reply, + eventBus, + handler, +}: StreamingResponseOptions): Promise { + const startTime = Date.now(); + + // Check if streaming is requested + const streamHeader = request.headers["x-stream-response"]; + const shouldStream = streamHeader === "true"; + + // Parse the request body + const data = request.body as T; + + // Set up SSE response if streaming + if (shouldStream) { + reply.raw.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Transfer-Encoding": "chunked", + "X-Accel-Buffering": "no", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "true", + }); + + // Emit stream started event + await eventBus.emitAsync("StagehandStreamStarted", { + type: "StagehandStreamStarted", + timestamp: new Date(), + sessionId, + requestId, + }); + + await sendSSE( + reply, + { + type: "system", + data: { status: "starting" }, + }, + eventBus, + sessionId, + requestId, + ); + } + + let result: StreamingHandlerResult | null = null; + let handlerError: Error | null = null; + let actionId: string | undefined = undefined; + + try { + // Get or create the Stagehand instance with dynamic logger + const stagehand = await sessionManager.getStagehand( + sessionId, + shouldStream + ? async (message) => { + await sendSSE( + reply, + { + type: "log", + data: { + status: "running", + message, + }, + }, + eventBus, + sessionId, + requestId, + ); + + // Emit action progress event + await eventBus.emitAsync("StagehandActionProgress", { + type: "StagehandActionProgress", + timestamp: new Date(), + sessionId, + requestId, + actionId, + actionType, + message, + }); + } + : undefined, + ); + + if (shouldStream) { + await sendSSE( + reply, + { + type: "system", + data: { status: "connected" }, + }, + eventBus, + sessionId, + requestId, + ); + } + + // Emit action started event + const page = await stagehand.context.awaitActivePage(); + const actionStartedEvent: StagehandActionStartedEvent = { + type: "StagehandActionStarted", + timestamp: new Date(), + sessionId, + requestId, + actionType, + input: (data as any).input || (data as any).instruction || (data as any).url || "", + options: (data as any).options || {}, + url: page?.url() || "", + frameId: (data as any).frameId, + }; + await eventBus.emitAsync("StagehandActionStarted", actionStartedEvent); + // Cloud listeners can set actionId on the event + actionId = actionStartedEvent.actionId; + + // Execute the handler + const ctx: StreamingHandlerContext = { + stagehand, + sessionId, + requestId, + request, + actionType, + eventBus, + }; + + result = await handler(ctx, data); + + // Emit action completed event + await eventBus.emitAsync("StagehandActionCompleted", { + type: "StagehandActionCompleted", + timestamp: new Date(), + sessionId, + requestId, + actionId, + actionType, + result: result?.result, + metrics: (stagehand as any).metrics + ? { + promptTokens: (stagehand as any).metrics.totalPromptTokens || 0, + completionTokens: (stagehand as any).metrics.totalCompletionTokens || 0, + inferenceTimeMs: 0, + } + : undefined, + durationMs: Date.now() - startTime, + }); + } catch (err) { + handlerError = err instanceof Error ? err : new Error("Unknown error occurred"); + + // Emit action error event + await eventBus.emitAsync("StagehandActionErrored", { + type: "StagehandActionErrored", + timestamp: new Date(), + sessionId, + requestId, + actionId, + actionType, + error: { + message: handlerError.message, + stack: handlerError.stack, + }, + durationMs: Date.now() - startTime, + }); + } + + // Handle error case + if (handlerError) { + const errorMessage = handlerError.message || "An unexpected error occurred"; + + if (shouldStream) { + await sendSSE( + reply, + { + type: "system", + data: { + status: "error", + error: errorMessage, + }, + }, + eventBus, + sessionId, + requestId, + ); + + // Emit stream ended event + await eventBus.emitAsync("StagehandStreamEnded", { + type: "StagehandStreamEnded", + timestamp: new Date(), + sessionId, + requestId, + }); + + reply.raw.end(); + } else { + reply.status(500).send({ + error: errorMessage, + }); + } + return; + } + + // Handle success case + if (shouldStream) { + await sendSSE( + reply, + { + type: "system", + data: { + status: "finished", + result: result?.result, + }, + }, + eventBus, + sessionId, + requestId, + ); + + // Emit stream ended event + await eventBus.emitAsync("StagehandStreamEnded", { + type: "StagehandStreamEnded", + timestamp: new Date(), + sessionId, + requestId, + }); + + reply.raw.end(); + } else { + reply.status(200).send({ + result: result?.result, + }); + } +} diff --git a/packages/core/lib/v3/types/private/api.ts b/packages/core/lib/v3/types/private/api.ts index 230584d28..a96f54717 100644 --- a/packages/core/lib/v3/types/private/api.ts +++ b/packages/core/lib/v3/types/private/api.ts @@ -10,8 +10,9 @@ import type { Protocol } from "devtools-protocol"; import type { StagehandZodSchema } from "../../zodCompat"; export interface StagehandAPIConstructorParams { - apiKey: string; - projectId: string; + apiKey?: string; + projectId?: string; + baseUrl?: string; logger: (message: LogLine) => void; } diff --git a/packages/core/lib/v3/types/public/options.ts b/packages/core/lib/v3/types/public/options.ts index 3e5f45e43..3ccb4cfe4 100644 --- a/packages/core/lib/v3/types/public/options.ts +++ b/packages/core/lib/v3/types/public/options.ts @@ -2,6 +2,7 @@ import Browserbase from "@browserbasehq/sdk"; import { LLMClient } from "../../llm/LLMClient"; import { ModelConfiguration } from "./model"; import { LogLine } from "./logs"; +import type { StagehandEventBus } from "../../eventBus"; export type V3Env = "LOCAL" | "BROWSERBASE"; @@ -86,4 +87,6 @@ export interface V3Options { cacheDir?: string; domSettleTimeout?: number; disableAPI?: boolean; + /** Optional shared event bus. If not provided, a new one will be created. */ + eventBus?: StagehandEventBus; } diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 3c795d8ac..78e5d471c 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import type { InferStagehandSchema, StagehandZodSchema } from "./zodCompat"; import { loadApiKeyFromEnv } from "../utils"; import { StagehandLogger, LoggerOptions } from "../logger"; +import { StagehandEventBus, createEventBus } from "./eventBus"; import { ActCache } from "./cache/ActCache"; import { AgentCache } from "./cache/AgentCache"; import { CacheStorage } from "./cache/CacheStorage"; @@ -123,6 +124,7 @@ dotenv.config({ path: ".env" }); * - Provides a stable API surface for downstream code regardless of runtime environment. */ export class V3 { + public readonly eventBus: StagehandEventBus; private readonly opts: V3Options; private state: InitState = { kind: "UNINITIALIZED" }; private actHandler: ActHandler | null = null; @@ -170,6 +172,7 @@ export class V3 { private actCache: ActCache; private agentCache: AgentCache; private apiClient: StagehandAPIClient | null = null; + private llmEventHandlerCleanup: (() => void) | null = null; public stagehandMetrics: StagehandMetrics = { actPromptTokens: 0, @@ -200,6 +203,9 @@ export class V3 { }; constructor(opts: V3Options) { + // Create event bus first - both library and server will use this + this.eventBus = opts.eventBus || createEventBus(); + V3._installProcessGuards(); this.externalLogger = opts.logger; this.verbose = opts.verbose ?? 1; @@ -304,6 +310,14 @@ export class V3 { act: this.act.bind(this), }); + // Initialize LLM event handler to listen for LLM requests on the event bus + const { initializeLLMEventHandler } = require("./llm/llmEventHandler"); + this.llmEventHandlerCleanup = initializeLLMEventHandler({ + eventBus: this.eventBus, + llmClient: this.llmClient, + logger: this.logger, + }); + this.opts = opts; // Track instance for global process guard handling V3._instances.add(this); @@ -960,6 +974,26 @@ export class V3 { async act(input: string | Action, options?: ActOptions): Promise { return await withInstanceLogContext(this.instanceId, async () => { + // If connected to remote server, route to API immediately + if (this.apiClient) { + if (isObserveResult(input)) { + // For Action objects, we can send directly to the API + return await this.apiClient.act({ + input, + options, + frameId: undefined, // Let the server resolve the page + }); + } else { + // For string instructions, send to API + return await this.apiClient.act({ + input, + options, + frameId: undefined, + }); + } + } + + // Local execution path if (!this.actHandler) throw new StagehandNotInitializedError("act()"); let actResult: ActResult; @@ -970,21 +1004,12 @@ export class V3 { // Use selector as provided to support XPath, CSS, and other engines const selector = input.selector; - if (this.apiClient) { - actResult = await this.apiClient.act({ - input, - options, - frameId: v3Page.mainFrameId(), - }); - } else { - actResult = await this.actHandler.actFromObserveResult( - { ...input, selector }, // ObserveResult - v3Page, // V3 Page - this.domSettleTimeoutMs, - this.resolveLlmClient(options?.model), - ); - } - + actResult = await this.actHandler.actFromObserveResult( + { ...input, selector }, // ObserveResult + v3Page, // V3 Page + this.domSettleTimeoutMs, + this.resolveLlmClient(options?.model), + ); // history: record ObserveResult-based act call this.addToHistory( "act", @@ -1047,12 +1072,7 @@ export class V3 { timeout: options?.timeout, model: options?.model, }; - if (this.apiClient) { - const frameId = page.mainFrameId(); - actResult = await this.apiClient.act({ input, options, frameId }); - } else { - actResult = await this.actHandler.act(handlerParams); - } + actResult = await this.actHandler.act(handlerParams); // history: record instruction-based act call (omit page object) this.addToHistory( "act", @@ -1107,10 +1127,6 @@ export class V3 { c?: ExtractOptions, ): Promise { return await withInstanceLogContext(this.instanceId, async () => { - if (!this.extractHandler) { - throw new StagehandNotInitializedError("extract()"); - } - // Normalize args let instruction: string | undefined; let schema: StagehandZodSchema | undefined; @@ -1144,7 +1160,17 @@ export class V3 { const effectiveSchema = instruction && !schema ? defaultExtractSchema : schema; - // Resolve page from options or use active page + // If connected to remote API (BROWSERBASE or P2P), route there immediately + if (this.apiClient) { + return await this.apiClient.extract({ + instruction, + schema: effectiveSchema, + options, + frameId: undefined, // Let server resolve the page + }); + } + + // Local execution path const page = await this.resolvePage(options?.page); const handlerParams: ExtractHandlerParams = { @@ -1155,19 +1181,8 @@ export class V3 { selector: options?.selector, page, }; - let result: z.infer | { pageText: string }; - if (this.apiClient) { - const frameId = page.mainFrameId(); - result = await this.apiClient.extract({ - instruction: handlerParams.instruction, - schema: handlerParams.schema, - options, - frameId, - }); - } else { - result = - await this.extractHandler.extract(handlerParams); - } + const result = + await this.extractHandler.extract(handlerParams); return result; }); } @@ -1186,10 +1201,6 @@ export class V3 { b?: ObserveOptions, ): Promise { return await withInstanceLogContext(this.instanceId, async () => { - if (!this.observeHandler) { - throw new StagehandNotInitializedError("observe()"); - } - // Normalize args let instruction: string | undefined; let options: ObserveOptions | undefined; @@ -1200,7 +1211,27 @@ export class V3 { options = a as ObserveOptions | undefined; } - // Resolve to our internal Page type + // If connected to remote API (BROWSERBASE or P2P), route there immediately + if (this.apiClient) { + const results = await this.apiClient.observe({ + instruction, + options, + frameId: undefined, // Let server resolve the page + }); + + // history: record observe call + this.addToHistory( + "observe", + { + instruction, + timeout: options?.timeout, + }, + results, + ); + return results; + } + + // Local execution path const page = await this.resolvePage(options?.page); const handlerParams: ObserveHandlerParams = { @@ -1211,17 +1242,7 @@ export class V3 { page: page!, }; - let results: Action[]; - if (this.apiClient) { - const frameId = page.mainFrameId(); - results = await this.apiClient.observe({ - instruction, - options, - frameId, - }); - } else { - results = await this.observeHandler.observe(handlerParams); - } + const results = await this.observeHandler.observe(handlerParams); // history: record observe call (omit page object) this.addToHistory( @@ -1236,6 +1257,24 @@ export class V3 { }); } + /** + * Navigate to a URL. When connected to a remote server, this routes the navigation + * to the remote browser. When running locally, it navigates the active page. + */ + async goto(url: string, options?: any): Promise { + return await withInstanceLogContext(this.instanceId, async () => { + // If connected to remote API (BROWSERBASE or P2P), route there + if (this.apiClient) { + await this.apiClient.goto(url, options); + return; + } + + // Local execution path + const page = await this.resolvePage(); + await page.goto(url, options); + }); + } + /** Return the browser-level CDP WebSocket endpoint. */ connectURL(): string { if (this.state.kind === "UNINITIALIZED") { @@ -1296,6 +1335,16 @@ export class V3 { } } } finally { + // Clean up LLM event handler + if (this.llmEventHandlerCleanup) { + try { + this.llmEventHandlerCleanup(); + this.llmEventHandlerCleanup = null; + } catch { + // ignore cleanup errors + } + } + // Reset internal state this.state = { kind: "UNINITIALIZED" }; this.ctx = null; @@ -1737,6 +1786,105 @@ export class V3 { }), }; } + + /** + * Create an HTTP server that handles Stagehand API requests. + * This allows other Stagehand instances to connect to this one and execute commands remotely. + * + * @param options - Server configuration options + * @returns StagehandServer instance + * + * @example + * ```typescript + * const stagehand = new Stagehand({ env: 'LOCAL' }); + * await stagehand.init(); + * + * const server = stagehand.createServer({ port: 3000 }); + * await server.listen(); + * ``` + */ + createServer( + options?: import("./server").StagehandServerOptions, + ): import("./server").StagehandServer { + // Import StagehandServer dynamically to avoid circular dependency + const { StagehandServer } = require("./server"); + return new StagehandServer(options); + } + + /** + * Connect to a remote Stagehand server and initialize a session. + * All act/extract/observe/agent calls will be forwarded to the remote server. + * + * @param baseUrl - URL of the remote Stagehand server (e.g., "http://localhost:3000") + * @param options - Optional configuration for the remote session + * + * @example + * ```typescript + * const stagehand = new Stagehand({ env: 'LOCAL' }); + * await stagehand.connectToRemoteServer('http://machine-a:3000'); + * await stagehand.act('click button'); // Executes on remote machine + * ``` + */ + async connectToRemoteServer( + baseUrl: string, + options?: Partial, + ): Promise { + if (this.apiClient) { + throw new Error( + "Already connected to a remote server or API. Cannot connect twice.", + ); + } + + // Ensure baseUrl includes /v1 to match cloud API pattern + const normalizedBaseUrl = baseUrl.endsWith('/v1') ? baseUrl : `${baseUrl}/v1`; + + this.apiClient = new StagehandAPIClient({ + baseUrl: normalizedBaseUrl, + logger: this.logger, + }); + + // Initialize a session on the remote server + const sessionConfig: V3Options = { + env: options?.env || this.opts.env, + model: options?.model || this.opts.model, + verbose: options?.verbose !== undefined ? options?.verbose : this.verbose, + systemPrompt: options?.systemPrompt || this.opts.systemPrompt, + selfHeal: options?.selfHeal !== undefined ? options?.selfHeal : this.opts.selfHeal, + domSettleTimeout: options?.domSettleTimeout || this.domSettleTimeoutMs, + experimental: options?.experimental !== undefined ? options?.experimental : this.experimental, + ...options, + }; + + // Call /sessions/start on the remote server + const response = await fetch(`${baseUrl}/v1/sessions/start`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-stream-response": "false", + }, + body: JSON.stringify(sessionConfig), + }); + + if (!response.ok) { + throw new Error( + `Failed to create session on remote server: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + if (!data.sessionId) { + throw new Error("Remote server did not return a session ID"); + } + + // Store the session ID in the API client + (this.apiClient as any).sessionId = data.sessionId; + + this.logger({ + category: "init", + message: `Connected to remote server at ${baseUrl} (session: ${data.sessionId})`, + level: 1, + }); + } } function isObserveResult(v: unknown): v is Action { diff --git a/packages/core/openapi.yaml b/packages/core/openapi.yaml new file mode 100644 index 000000000..378218891 --- /dev/null +++ b/packages/core/openapi.yaml @@ -0,0 +1,711 @@ +openapi: 3.0.3 +info: + title: Stagehand P2P Server API + description: | + HTTP API for remote Stagehand browser automation. This API allows clients to + connect to a Stagehand server and execute browser automation tasks remotely. + + All endpoints except /sessions/start require an active session ID. + Responses are streamed using Server-Sent Events (SSE) when the + `x-stream-response: true` header is provided. + version: 3.0.0 + contact: + name: Browserbase + url: https://browserbase.com + +servers: + - url: http://localhost:3000/v1 + description: Local P2P server + - url: https://api.stagehand.browserbase.com/v1 + description: Cloud API (for reference) + +paths: + /sessions/start: + post: + summary: Create a new browser session + description: | + Initializes a new Stagehand session with a browser instance. + Returns a session ID that must be used for all subsequent requests. + operationId: createSession + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SessionConfig' + examples: + local: + summary: Local browser session + value: + env: LOCAL + verbose: 1 + browserbase: + summary: Browserbase session + value: + env: BROWSERBASE + apiKey: bb_api_key_123 + projectId: proj_123 + verbose: 1 + responses: + '200': + description: Session created successfully + content: + application/json: + schema: + type: object + required: + - sessionId + - available + properties: + sessionId: + type: string + format: uuid + description: Unique identifier for the session + available: + type: boolean + description: Whether the session is ready to use + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/act: + post: + summary: Execute an action on the page + description: | + Performs a browser action based on natural language instruction or + a specific action object returned by observe(). + operationId: act + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActRequest' + examples: + stringInstruction: + summary: Natural language instruction + value: + input: "click the sign in button" + actionObject: + summary: Execute observed action + value: + input: + selector: "#login-btn" + description: "Sign in button" + method: "click" + arguments: [] + responses: + '200': + description: Action executed successfully + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/ActResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/extract: + post: + summary: Extract structured data from the page + description: | + Extracts data from the current page using natural language instructions + and optional JSON schema for structured output. + operationId: extract + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExtractRequest' + examples: + simple: + summary: Simple extraction + value: + instruction: "extract the page title" + withSchema: + summary: Structured extraction + value: + instruction: "extract all product listings" + schema: + type: object + properties: + products: + type: array + items: + type: object + properties: + name: + type: string + price: + type: string + responses: + '200': + description: Data extracted successfully + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/ExtractResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/observe: + post: + summary: Observe possible actions on the page + description: | + Returns a list of candidate actions that can be performed on the page, + optionally filtered by natural language instruction. + operationId: observe + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ObserveRequest' + examples: + allActions: + summary: Observe all actions + value: {} + filtered: + summary: Observe specific actions + value: + instruction: "find all buttons" + responses: + '200': + description: Actions observed successfully + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/ObserveResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/agentExecute: + post: + summary: Execute a multi-step agent task + description: | + Runs an autonomous agent that can perform multiple actions to + complete a complex task. + operationId: agentExecute + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentExecuteRequest' + examples: + basic: + summary: Basic agent task + value: + agentConfig: + model: "openai/gpt-4o" + executeOptions: + instruction: "Find and click the first product" + maxSteps: 10 + responses: + '200': + description: Agent task completed + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/AgentResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/navigate: + post: + summary: Navigate to a URL + description: | + Navigates the browser to the specified URL and waits for page load. + operationId: navigate + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NavigateRequest' + examples: + simple: + summary: Simple navigation + value: + url: "https://example.com" + withOptions: + summary: Navigation with options + value: + url: "https://example.com" + options: + waitUntil: "networkidle" + responses: + '200': + description: Navigation completed + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/NavigateResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/end: + post: + summary: End the session and cleanup resources + description: | + Closes the browser and cleans up all resources associated with the session. + operationId: endSession + parameters: + - $ref: '#/components/parameters/SessionId' + responses: + '200': + description: Session ended successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + '500': + $ref: '#/components/responses/InternalError' + +components: + parameters: + SessionId: + name: sessionId + in: path + required: true + description: The session ID returned by /sessions/start + schema: + type: string + format: uuid + + StreamResponse: + name: x-stream-response + in: header + description: Enable Server-Sent Events streaming for real-time logs + schema: + type: string + enum: ["true", "false"] + default: "true" + + schemas: + SessionConfig: + type: object + required: + - env + properties: + env: + type: string + enum: [LOCAL, BROWSERBASE] + description: Environment to run the browser in + verbose: + type: integer + minimum: 0 + maximum: 2 + default: 0 + description: Logging verbosity level + model: + type: string + description: AI model to use for actions + example: "openai/gpt-4o" + apiKey: + type: string + description: API key for Browserbase (required when env=BROWSERBASE) + projectId: + type: string + description: Project ID for Browserbase (required when env=BROWSERBASE) + systemPrompt: + type: string + description: Custom system prompt for AI actions + domSettleTimeout: + type: integer + description: Timeout in ms to wait for DOM to settle + selfHeal: + type: boolean + description: Enable self-healing for failed actions + localBrowserLaunchOptions: + type: object + description: Options for local browser launch + properties: + headless: + type: boolean + default: true + + ActRequest: + type: object + required: + - input + properties: + input: + oneOf: + - type: string + description: Natural language instruction + - $ref: '#/components/schemas/Action' + options: + $ref: '#/components/schemas/ActionOptions' + frameId: + type: string + description: Frame ID to act on (optional) + + Action: + type: object + required: + - selector + - description + - method + - arguments + properties: + selector: + type: string + description: CSS or XPath selector for the element + description: + type: string + description: Human-readable description of the action + backendNodeId: + type: integer + description: CDP backend node ID + method: + type: string + description: Method to execute (e.g., "click", "fill") + arguments: + type: array + items: + type: string + description: Arguments for the method + + ActionOptions: + type: object + properties: + model: + $ref: '#/components/schemas/ModelConfig' + variables: + type: object + additionalProperties: + type: string + description: Template variables for instruction + timeout: + type: integer + description: Timeout in milliseconds + + ModelConfig: + type: object + properties: + provider: + type: string + enum: [openai, anthropic, google] + model: + type: string + description: Model name + apiKey: + type: string + description: API key for the model provider + baseURL: + type: string + format: uri + description: Custom base URL for API + + ExtractRequest: + type: object + properties: + instruction: + type: string + description: Natural language instruction for extraction + schema: + type: object + description: JSON Schema for structured output + additionalProperties: true + options: + type: object + properties: + model: + $ref: '#/components/schemas/ModelConfig' + timeout: + type: integer + selector: + type: string + description: Extract only from elements matching this selector + frameId: + type: string + description: Frame ID to extract from + + ObserveRequest: + type: object + properties: + instruction: + type: string + description: Natural language instruction to filter actions + options: + type: object + properties: + model: + $ref: '#/components/schemas/ModelConfig' + timeout: + type: integer + selector: + type: string + description: Observe only elements matching this selector + frameId: + type: string + description: Frame ID to observe + + AgentExecuteRequest: + type: object + required: + - agentConfig + - executeOptions + properties: + agentConfig: + type: object + properties: + provider: + type: string + enum: [openai, anthropic, google] + model: + oneOf: + - type: string + - $ref: '#/components/schemas/ModelConfig' + systemPrompt: + type: string + cua: + type: boolean + description: Enable Computer Use Agent mode + executeOptions: + type: object + required: + - instruction + properties: + instruction: + type: string + description: Task for the agent to complete + maxSteps: + type: integer + default: 20 + description: Maximum number of steps the agent can take + highlightCursor: + type: boolean + description: Visually highlight the cursor during actions + frameId: + type: string + + NavigateRequest: + type: object + required: + - url + properties: + url: + type: string + format: uri + description: URL to navigate to + options: + type: object + properties: + waitUntil: + type: string + enum: [load, domcontentloaded, networkidle] + default: load + description: When to consider navigation complete + frameId: + type: string + + ActResult: + type: object + required: + - success + - message + - actions + properties: + success: + type: boolean + description: Whether the action succeeded + message: + type: string + description: Result message + actions: + type: array + items: + $ref: '#/components/schemas/Action' + description: Actions that were executed + + ExtractResult: + oneOf: + - type: object + description: Default extraction result + properties: + extraction: + type: string + - type: object + description: Structured data matching provided schema + additionalProperties: true + + ObserveResult: + type: array + items: + $ref: '#/components/schemas/Action' + description: List of observed actions + + AgentResult: + type: object + properties: + message: + type: string + description: Final message from the agent + steps: + type: array + items: + type: object + description: Steps taken by the agent + + NavigateResult: + type: object + nullable: true + description: Navigation response (may be null) + properties: + ok: + type: boolean + status: + type: integer + url: + type: string + + SSEResponse: + description: | + Server-Sent Events stream. Each event is prefixed with "data: " + and contains a JSON object with type and data fields. + type: object + required: + - id + - type + - data + properties: + id: + type: string + format: uuid + description: Unique event ID + type: + type: string + enum: [system, log] + description: Event type + data: + oneOf: + - $ref: '#/components/schemas/SystemEvent' + - $ref: '#/components/schemas/LogEvent' + + SystemEvent: + type: object + properties: + status: + type: string + enum: [starting, connected, finished, error] + description: System status + result: + description: Result data (present when status=finished) + error: + type: string + description: Error message (present when status=error) + + LogEvent: + type: object + required: + - status + - message + properties: + status: + type: string + const: running + message: + type: object + required: + - category + - message + - level + properties: + category: + type: string + description: Log category + message: + type: string + description: Log message + level: + type: integer + minimum: 0 + maximum: 2 + description: Log level (0=error, 1=info, 2=debug) + + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + description: Error message + details: + description: Additional error details + + responses: + BadRequest: + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + SessionNotFound: + description: Session ID not found or expired + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + InternalError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/packages/core/package.json b/packages/core/package.json index 0152611f9..480f6c9b7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,10 +5,22 @@ "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./server": { + "import": "./dist/server.js", + "require": "./dist/server.js", + "types": "./dist/server.d.ts" + } + }, "scripts": { "gen-version": "tsx scripts/gen-version.ts", "build-dom-scripts": "tsx lib/v3/dom/genDomScripts.ts && tsx lib/v3/dom/genLocatorScripts.ts", - "build-js": "tsup --entry.index lib/v3/index.ts --dts", + "build-js": "tsup --entry.index lib/v3/index.ts --entry.server lib/v3/server/index.ts --dts", "typecheck": "tsc --noEmit", "prepare": "pnpm run build", "build": "pnpm run gen-version && pnpm run build-dom-scripts && pnpm run build-js && pnpm run typecheck", @@ -46,11 +58,13 @@ "@ai-sdk/provider": "^2.0.0", "@anthropic-ai/sdk": "0.39.0", "@browserbasehq/sdk": "^2.4.0", + "@fastify/cors": "^10.0.1", "@google/genai": "^1.22.0", "@langchain/openai": "^0.4.4", "@modelcontextprotocol/sdk": "^1.17.2", "ai": "^5.0.0", "devtools-protocol": "^0.0.1464554", + "fastify": "^5.2.4", "fetch-cookie": "^3.1.0", "openai": "^4.87.1", "pino": "^9.6.0", diff --git a/packages/core/scripts/generate-openapi.ts b/packages/core/scripts/generate-openapi.ts new file mode 100644 index 000000000..ea22003cd --- /dev/null +++ b/packages/core/scripts/generate-openapi.ts @@ -0,0 +1,280 @@ +/** + * Generate OpenAPI schema from Zod schemas + * + * Run: npx tsx scripts/generate-openapi.ts + * + * This script imports the actual Zod schemas from lib/v3/server/schemas.ts + * to ensure the OpenAPI spec stays in sync with the implementation. + */ + +import { extendZodWithOpenApi, OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Import actual schemas from server +import { + actSchemaV3, + extractSchemaV3, + observeSchemaV3, + agentExecuteSchemaV3, + navigateSchemaV3, +} from '../lib/v3/server/schemas'; + +// Extend Zod with OpenAPI +extendZodWithOpenApi(z); + +// Create registry +const registry = new OpenAPIRegistry(); + +// Register the schemas with OpenAPI names +registry.register('ActRequest', actSchemaV3); +registry.register('ExtractRequest', extractSchemaV3); +registry.register('ObserveRequest', observeSchemaV3); +registry.register('AgentExecuteRequest', agentExecuteSchemaV3); +registry.register('NavigateRequest', navigateSchemaV3); + +// Response Schemas +const ActResultSchema = z.object({ + success: z.boolean(), + message: z.string(), + actions: z.array(ActionSchema) +}).openapi('ActResult'); + +const ExtractResultSchema = z.unknown().openapi('ExtractResult', { + description: 'Extracted data matching provided schema or default extraction object' +}); + +const ObserveResultSchema = z.array(ActionSchema).openapi('ObserveResult'); + +const AgentResultSchema = z.object({ + message: z.string().optional(), + steps: z.array(z.unknown()).optional() +}).openapi('AgentResult'); + +const ErrorResponseSchema = z.object({ + error: z.string(), + details: z.unknown().optional() +}).openapi('ErrorResponse'); + +// ============================================================================ +// Register Routes +// ============================================================================ + +// POST /sessions/start +registry.registerPath({ + method: 'post', + path: '/sessions/start', + summary: 'Create a new browser session', + description: 'Initializes a new Stagehand session with a browser instance. Returns a session ID that must be used for all subsequent requests.', + request: { + body: { + content: { + 'application/json': { + schema: SessionConfigSchema + } + } + } + }, + responses: { + 200: { + description: 'Session created successfully', + content: { + 'application/json': { + schema: z.object({ + sessionId: z.string().uuid(), + available: z.boolean() + }) + } + } + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + } + } +}); + +// Helper to create session-based route +function registerSessionRoute( + path: string, + summary: string, + description: string, + requestSchema: z.ZodTypeAny, + responseSchema: z.ZodTypeAny +) { + registry.registerPath({ + method: 'post', + path: `/sessions/{sessionId}/${path}`, + summary, + description, + request: { + params: z.object({ + sessionId: z.string().uuid() + }), + headers: z.object({ + 'x-stream-response': z.enum(['true', 'false']).optional() + }).passthrough(), + body: { + content: { + 'application/json': { + schema: requestSchema + } + } + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: responseSchema + }, + 'text/event-stream': { + schema: z.string() + } + } + }, + 400: { + description: 'Invalid request', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + }, + 404: { + description: 'Session not found', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + } + } + }); +} + +// Register all session routes using imported schemas +registerSessionRoute( + 'act', + 'Execute an action on the page', + 'Performs a browser action based on natural language instruction or a specific action object.', + actSchemaV3, + ActResultSchema +); + +registerSessionRoute( + 'extract', + 'Extract structured data from the page', + 'Extracts data from the current page using natural language instructions and optional JSON schema.', + extractSchemaV3, + ExtractResultSchema +); + +registerSessionRoute( + 'observe', + 'Observe possible actions on the page', + 'Returns a list of candidate actions that can be performed on the page.', + observeSchemaV3, + ObserveResultSchema +); + +registerSessionRoute( + 'agentExecute', + 'Execute a multi-step agent task', + 'Runs an autonomous agent that can perform multiple actions to complete a complex task.', + agentExecuteSchemaV3, + AgentResultSchema +); + +registerSessionRoute( + 'navigate', + 'Navigate to a URL', + 'Navigates the browser to the specified URL and waits for page load.', + navigateSchemaV3, + z.unknown() +); + +// POST /sessions/{sessionId}/end +registry.registerPath({ + method: 'post', + path: '/sessions/{sessionId}/end', + summary: 'End the session and cleanup resources', + description: 'Closes the browser and cleans up all resources associated with the session.', + request: { + params: z.object({ + sessionId: z.string().uuid() + }) + }, + responses: { + 200: { + description: 'Session ended', + content: { + 'application/json': { + schema: z.object({ + success: z.boolean() + }) + } + } + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + } + } +}); + +// ============================================================================ +// Generate OpenAPI Document +// ============================================================================ + +const generator = new OpenApiGeneratorV3(registry.definitions); + +const openApiDocument = generator.generateDocument({ + openapi: '3.0.3', + info: { + title: 'Stagehand P2P Server API', + version: '3.0.0', + description: `HTTP API for remote Stagehand browser automation. This API allows clients to connect to a Stagehand server and execute browser automation tasks remotely. + +All endpoints except /sessions/start require an active session ID. Responses are streamed using Server-Sent Events (SSE) when the \`x-stream-response: true\` header is provided.`, + contact: { + name: 'Browserbase', + url: 'https://browserbase.com' + } + }, + servers: [ + { + url: 'http://localhost:3000/v1', + description: 'Local P2P server' + }, + { + url: 'https://api.stagehand.browserbase.com/v1', + description: 'Cloud API' + } + ] +}); + +// Write to file +const outputPath = path.join(__dirname, '..', 'openapi.yaml'); +const yaml = require('yaml'); +fs.writeFileSync(outputPath, yaml.stringify(openApiDocument)); + +console.log(`✓ OpenAPI schema generated at: ${outputPath}`); diff --git a/packages/core/tests/integration/p2p-server-client.test.ts b/packages/core/tests/integration/p2p-server-client.test.ts new file mode 100644 index 000000000..458626d8a --- /dev/null +++ b/packages/core/tests/integration/p2p-server-client.test.ts @@ -0,0 +1,425 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Stagehand, StagehandServer } from "../../dist/index.js"; +import { z } from "zod/v3"; + +/** + * Integration test for P2P Server/Client functionality + * + * This test spins up a local Stagehand server and connects a client to it, + * then verifies that all RPC calls (act, extract, observe, agentExecute) + * work correctly through the remote connection. + */ +describe("P2P Server/Client Integration", () => { + let server: StagehandServer; + let serverStagehand: Stagehand; + let clientStagehand: Stagehand; + const SERVER_PORT = 3123; // Use a non-standard port to avoid conflicts + const SERVER_URL = `http://localhost:${SERVER_PORT}`; + + beforeAll(async () => { + // Create the server-side Stagehand instance + serverStagehand = new Stagehand({ + env: "LOCAL", + verbose: 0, // Suppress logs during tests + localBrowserLaunchOptions: { + headless: true, + }, + }); + + await serverStagehand.init(); + + // Create and start the server + server = serverStagehand.createServer({ + port: SERVER_PORT, + host: "127.0.0.1", // Use localhost for testing + }); + + await server.listen(); + + // Give the server a moment to fully start + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Create the client-side Stagehand instance + clientStagehand = new Stagehand({ + env: "LOCAL", + verbose: 0, + }); + + // Connect the client to the server + clientStagehand.connectToRemoteServer(SERVER_URL); + + // Initialize a session on the server by calling /sessions/start + // This is done automatically when we make our first RPC call + }, 30000); // 30 second timeout for setup + + afterAll(async () => { + // Clean up: close client, server, and browser + try { + if (server) { + await server.close(); + } + if (serverStagehand) { + await serverStagehand.close(); + } + } catch (error) { + console.error("Error during cleanup:", error); + } + }, 30000); + + describe("Server Setup", () => { + it("should have server listening", () => { + expect(server).toBeDefined(); + expect(server.getUrl()).toBe(`http://127.0.0.1:${SERVER_PORT}`); + }); + + it("should have client connected", () => { + expect(clientStagehand).toBeDefined(); + // The client should have an apiClient set + expect((clientStagehand as any).apiClient).toBeDefined(); + }); + }); + + describe("act() RPC call", () => { + it("should execute act() remotely and return expected shape", async () => { + // Navigate to a test page on the server + const page = await serverStagehand.context.awaitActivePage(); + await page.goto("data:text/html,"); + + // Give the page time to load + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Now execute act() through the client (which will RPC to the server) + const result = await clientStagehand.act("click the button"); + + // Verify the result has the expected shape + expect(result).toBeDefined(); + expect(result).toHaveProperty("success"); + expect(result.success).toBe(true); + + // ActResult should have these properties + if (result.success) { + expect(result).toHaveProperty("message"); + expect(result).toHaveProperty("actions"); + expect(typeof result.message).toBe("string"); + + // Actions should be an array + expect(Array.isArray(result.actions)).toBe(true); + if (result.actions.length > 0) { + expect(result.actions[0]).toHaveProperty("selector"); + expect(typeof result.actions[0].selector).toBe("string"); + } + } + }, 30000); + + it("should execute act() with Action object", async () => { + // Navigate to a test page + const page = await serverStagehand.context.awaitActivePage(); + await page.goto("data:text/html,Link"); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Get actions via observe + const actions = await clientStagehand.observe("click the link"); + + expect(actions).toBeDefined(); + expect(Array.isArray(actions)).toBe(true); + expect(actions.length).toBeGreaterThan(0); + + // Execute the first action + const result = await clientStagehand.act(actions[0]); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("success"); + }, 30000); + }); + + describe("extract() RPC call", () => { + it("should extract data without schema and return expected shape", async () => { + // Navigate to a test page with content to extract + const page = await serverStagehand.context.awaitActivePage(); + await page.goto( + "data:text/html,

Test Title

Test content paragraph.

" + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Extract without a schema (returns { extraction: string }) + const result = await clientStagehand.extract("extract the heading text"); + + // Verify result shape + expect(result).toBeDefined(); + expect(result).toHaveProperty("extraction"); + expect(typeof result.extraction).toBe("string"); + + // The extraction should contain relevant text + const extraction = result.extraction as string; + expect(extraction.toLowerCase()).toContain("test"); + }, 30000); + + it("should extract data with zod schema and return expected shape", async () => { + // Navigate to a test page with structured content + const page = await serverStagehand.context.awaitActivePage(); + await page.goto( + "data:text/html," + + "
Item 1$10
" + + "
Item 2$20
" + + "" + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Define a schema + const schema = z.object({ + items: z.array( + z.object({ + name: z.string(), + price: z.string(), + }) + ), + }); + + // Extract with schema + const result = await clientStagehand.extract( + "extract all items with their names and prices", + schema + ); + + // Verify result shape matches schema + expect(result).toBeDefined(); + expect(result).toHaveProperty("items"); + expect(Array.isArray(result.items)).toBe(true); + expect(result.items.length).toBeGreaterThan(0); + + // Check first item structure + const firstItem = result.items[0]; + expect(firstItem).toHaveProperty("name"); + expect(firstItem).toHaveProperty("price"); + expect(typeof firstItem.name).toBe("string"); + expect(typeof firstItem.price).toBe("string"); + }, 30000); + + it("should extract with selector option", async () => { + // Navigate to a test page + const page = await serverStagehand.context.awaitActivePage(); + await page.goto( + "data:text/html," + + "

Target Text

" + + "" + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Extract from specific selector + const result = await clientStagehand.extract( + "extract the text", + z.string(), + { selector: "#target" } + ); + + expect(result).toBeDefined(); + expect(typeof result).toBe("string"); + expect((result as string).toLowerCase()).toContain("target"); + }, 30000); + }); + + describe("observe() RPC call", () => { + it("should observe actions and return expected shape", async () => { + // Navigate to a test page with multiple elements + const page = await serverStagehand.context.awaitActivePage(); + await page.goto( + "data:text/html," + + "" + + "" + + "Link" + + "" + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Observe possible actions + const actions = await clientStagehand.observe("click a button"); + + // Verify result shape + expect(actions).toBeDefined(); + expect(Array.isArray(actions)).toBe(true); + expect(actions.length).toBeGreaterThan(0); + + // Check first action structure + const firstAction = actions[0]; + expect(firstAction).toHaveProperty("selector"); + expect(firstAction).toHaveProperty("description"); + expect(typeof firstAction.selector).toBe("string"); + expect(typeof firstAction.description).toBe("string"); + + // Actions should have method property + if (firstAction.method) { + expect(typeof firstAction.method).toBe("string"); + } + }, 30000); + + it("should observe without instruction", async () => { + // Navigate to a test page + const page = await serverStagehand.context.awaitActivePage(); + await page.goto( + "data:text/html," + + "" + + "" + + "" + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Observe all available actions + const actions = await clientStagehand.observe(); + + expect(actions).toBeDefined(); + expect(Array.isArray(actions)).toBe(true); + // Should find multiple interactive elements + expect(actions.length).toBeGreaterThan(0); + + // Each action should have required properties + actions.forEach((action) => { + expect(action).toHaveProperty("selector"); + expect(action).toHaveProperty("description"); + }); + }, 30000); + }); + + describe("agentExecute() RPC call", () => { + it("should execute agent task and return expected shape", async () => { + // Navigate to a simple test page + const page = await serverStagehand.context.awaitActivePage(); + await page.goto( + "data:text/html," + + "

Agent Test Page

" + + "" + + "" + + "
" + + "" + + "" + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Execute agent task through RPC + const agent = clientStagehand.agent({ + model: process.env.OPENAI_API_KEY ? "openai/gpt-4o-mini" : undefined, + systemPrompt: "Complete the task efficiently", + }); + + const result = await agent.execute({ + instruction: "Click Step 1 button", + maxSteps: 3, + }); + + // Verify result shape + expect(result).toBeDefined(); + expect(result).toHaveProperty("success"); + expect(typeof result.success).toBe("boolean"); + + if (result.success) { + expect(result).toHaveProperty("message"); + expect(typeof result.message).toBe("string"); + } + + // AgentResult should have actions + if (result.actions) { + expect(Array.isArray(result.actions)).toBe(true); + } + }, 60000); // Longer timeout for agent execution + }); + + describe("Session Management", () => { + it("should track active sessions on server", () => { + const sessionCount = server.getActiveSessionCount(); + expect(sessionCount).toBeGreaterThan(0); + }); + + it("should handle multiple concurrent requests", async () => { + // Navigate to a test page + const page = await serverStagehand.context.awaitActivePage(); + await page.goto( + "data:text/html," + + "

Concurrent Test

" + + "" + + "" + + "" + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Execute multiple operations concurrently + const [extractResult, observeResult] = await Promise.all([ + clientStagehand.extract("extract the heading text"), + clientStagehand.observe("find buttons"), + ]); + + // Both should succeed + expect(extractResult).toBeDefined(); + expect(observeResult).toBeDefined(); + expect(Array.isArray(observeResult)).toBe(true); + }, 30000); + }); + + describe("Error Handling", () => { + it("should handle invalid session ID gracefully", async () => { + // This test verifies error handling, but since we're using + // an established session, we'll test with an invalid action + + const page = await serverStagehand.context.awaitActivePage(); + await page.goto("data:text/html,

No buttons here

"); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Try to act on a non-existent element + // This should either return success: false or throw an error + try { + const result = await clientStagehand.act("click the non-existent super special button that definitely does not exist"); + + // If it doesn't throw, check the result + expect(result).toBeDefined(); + // It should indicate failure in some way + if ('success' in result) { + // Result structure is valid even if action failed + expect(typeof result.success).toBe("boolean"); + } + } catch (error) { + // If it throws, that's also acceptable error handling + expect(error).toBeDefined(); + } + }, 30000); + }); + + describe("Type Safety", () => { + it("should maintain type information through RPC", async () => { + const page = await serverStagehand.context.awaitActivePage(); + await page.goto( + "data:text/html,42" + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Extract with a typed schema + const schema = z.object({ + value: z.number(), + }); + + const result = await clientStagehand.extract( + "extract the number from the span", + schema + ); + + // TypeScript should know this is { value: number } + expect(result).toHaveProperty("value"); + expect(typeof result.value).toBe("number"); + }, 30000); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34aa34dab..e4b96764c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 1.2.0 chromium-bidi: specifier: ^0.10.0 - version: 0.10.2(devtools-protocol@0.0.1312386) + version: 0.10.2(devtools-protocol@0.0.1464554) esbuild: specifier: ^0.21.4 version: 0.21.5 @@ -125,6 +125,9 @@ importers: '@browserbasehq/sdk': specifier: ^2.4.0 version: 2.5.0 + '@fastify/cors': + specifier: ^10.0.1 + version: 10.1.0 '@google/genai': specifier: ^1.22.0 version: 1.24.0(@modelcontextprotocol/sdk@1.17.2)(bufferutil@4.0.9) @@ -146,6 +149,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.5.0 + fastify: + specifier: ^5.2.4 + version: 5.6.2 fetch-cookie: specifier: ^3.1.0 version: 3.1.0 @@ -167,31 +173,6 @@ importers: zod-to-json-schema: specifier: ^3.25.0 version: 3.25.0(zod@4.1.8) - devDependencies: - '@playwright/test': - specifier: ^1.42.1 - version: 1.54.2 - eslint: - specifier: ^9.16.0 - version: 9.25.1(jiti@1.21.7) - prettier: - specifier: ^3.2.5 - version: 3.5.3 - tsup: - specifier: ^8.2.1 - version: 8.4.0(jiti@1.21.7)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.7.1) - tsx: - specifier: ^4.10.5 - version: 4.19.4 - typescript: - specifier: ^5.2.2 - version: 5.8.3 - vitest: - specifier: ^4.0.8 - version: 4.0.8(@types/debug@4.1.12)(@types/node@20.17.32)(jiti@1.21.7)(tsx@4.19.4)(yaml@2.7.1) - zod: - specifier: 3.25.76 || 4.1.8 - version: 4.1.8 optionalDependencies: '@ai-sdk/anthropic': specifier: ^2.0.34 @@ -247,6 +228,31 @@ importers: puppeteer-core: specifier: ^22.8.0 version: 22.15.0(bufferutil@4.0.9) + devDependencies: + '@playwright/test': + specifier: ^1.42.1 + version: 1.54.2 + eslint: + specifier: ^9.16.0 + version: 9.25.1(jiti@1.21.7) + prettier: + specifier: ^3.2.5 + version: 3.5.3 + tsup: + specifier: ^8.2.1 + version: 8.4.0(jiti@1.21.7)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.7.1) + tsx: + specifier: ^4.10.5 + version: 4.19.4 + typescript: + specifier: ^5.2.2 + version: 5.8.3 + vitest: + specifier: ^4.0.8 + version: 4.0.8(@types/debug@4.1.12)(@types/node@20.17.32)(jiti@1.21.7)(tsx@4.19.4)(yaml@2.7.1) + zod: + specifier: 3.25.76 || 4.1.8 + version: 4.1.8 packages/docs: dependencies: @@ -981,6 +987,27 @@ packages: resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@10.1.0': + resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@google/genai@1.24.0': resolution: {integrity: sha512-e3jZF9Dx3dDaDCzygdMuYByHI2xJZ0PaD3r2fRgHZe2IOwBnmJ/Tu5Lt/nefTCxqr1ZnbcbQK9T13d8U/9UMWg==} engines: {node: '>=20.0.0'} @@ -1811,6 +1838,9 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2375,6 +2405,9 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2569,6 +2602,9 @@ packages: resolution: {integrity: sha512-9cYNccliXZDByFsFliVwk5GvTq058Fj513CiR4E60ndDwmuXzTJEp/Bp8FyuRmGyYupLjHLs+JA9/CBoVS4/NQ==} engines: {node: '>=0.11'} + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + axios@1.13.0: resolution: {integrity: sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==} @@ -2941,6 +2977,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3459,6 +3499,9 @@ packages: fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3472,12 +3515,18 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.1.1: + resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} fast-memoize@2.5.2: resolution: {integrity: sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-redact@3.5.0: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} @@ -3488,6 +3537,12 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.6.2: + resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -3544,6 +3599,10 @@ packages: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} + find-my-way@9.3.0: + resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} + engines: {node: '>=20'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -3972,6 +4031,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.2.0: + resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + engines: {node: '>= 10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -4210,6 +4273,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -4307,6 +4373,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lighthouse-logger@2.0.2: resolution: {integrity: sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==} @@ -4696,6 +4765,9 @@ packages: ml-xsadd@3.0.1: resolution: {integrity: sha512-Fz2q6dwgzGM8wYKGArTUTZDGa4lQFA2Vi6orjGeTVRy22ZnQFKlJuwS9n8NRviqz1KHAHAzdKJwbnYhdo38uYg==} + mnemonist@0.40.0: + resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4821,6 +4893,9 @@ packages: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + ollama-ai-provider-v2@1.5.0: resolution: {integrity: sha512-o8nR80AaENpetYdCtlnZGEwO47N6Z6eEsBetitrh4nTXrbfWdRF4OWE5p5oHyo0R8sxg4zdxUsjmGVzPd3zBUQ==} engines: {node: '>=18'} @@ -5080,6 +5155,10 @@ packages: pino-std-serializers@7.0.0: resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + pino@10.1.0: + resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==} + hasBin: true + pino@9.6.0: resolution: {integrity: sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==} hasBin: true @@ -5193,6 +5272,9 @@ packages: process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -5430,6 +5512,10 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -5456,6 +5542,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.40.1: resolution: {integrity: sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -5498,6 +5587,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + safe-stable-stringify@1.1.1: resolution: {integrity: sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==} @@ -5521,6 +5613,9 @@ packages: secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@7.7.1: resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} engines: {node: '>=10'} @@ -5867,6 +5962,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -7218,6 +7317,34 @@ snapshots: '@eslint/core': 0.13.0 levn: 0.4.1 + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.0.6 + + '@fastify/cors@10.1.0': + dependencies: + fastify-plugin: 5.1.0 + mnemonist: 0.40.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.1.1 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.2.0 + '@google/genai@1.24.0(@modelcontextprotocol/sdk@1.17.2)(bufferutil@4.0.9)': dependencies: google-auth-library: 9.15.1 @@ -8010,6 +8137,8 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -8555,7 +8684,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.1 + semver: 7.7.2 ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -8626,6 +8755,8 @@ snapshots: dependencies: event-target-shim: 5.0.1 + abstract-logging@2.0.1: {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -8818,6 +8949,11 @@ snapshots: avsc@5.7.7: {} + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.19.1 + axios@1.13.0(debug@4.4.3): dependencies: follow-redirects: 1.15.9(debug@4.4.3) @@ -9105,9 +9241,9 @@ snapshots: transitivePeerDependencies: - supports-color - chromium-bidi@0.10.2(devtools-protocol@0.0.1312386): + chromium-bidi@0.10.2(devtools-protocol@0.0.1464554): dependencies: - devtools-protocol: 0.0.1312386 + devtools-protocol: 0.0.1464554 mitt: 3.0.1 zod: 3.23.8 @@ -9235,6 +9371,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + core-util-is@1.0.3: {} cors@2.8.5: @@ -9924,6 +10062,8 @@ snapshots: fast-copy@3.0.2: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -9938,16 +10078,49 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.1.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.0.6 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} fast-memoize@2.5.2: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-redact@3.5.0: {} fast-safe-stringify@2.1.1: {} fast-uri@3.0.6: {} + fastify-plugin@5.1.0: {} + + fastify@5.6.2: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.1.1 + find-my-way: 9.3.0 + light-my-request: 6.6.0 + pino: 10.1.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.2 + toad-cache: 3.7.0 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -10018,6 +10191,12 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way@9.3.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -10546,7 +10725,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.13.0) + retry-axios: 2.6.0(axios@1.13.0(debug@4.4.3)) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -10646,6 +10825,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.2.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -10862,6 +11043,10 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -10942,7 +11127,7 @@ snapshots: console-table-printer: 2.12.1 p-queue: 6.6.2 p-retry: 4.6.2 - semver: 7.7.1 + semver: 7.7.2 uuid: 10.0.0 optionalDependencies: openai: 4.96.2(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67) @@ -10955,7 +11140,7 @@ snapshots: console-table-printer: 2.12.1 p-queue: 6.6.2 p-retry: 4.6.2 - semver: 7.7.1 + semver: 7.7.2 uuid: 10.0.0 optionalDependencies: openai: 4.96.2(ws@8.18.3(bufferutil@4.0.9))(zod@4.1.8) @@ -10967,7 +11152,7 @@ snapshots: console-table-printer: 2.12.1 p-queue: 6.6.2 p-retry: 4.6.2 - semver: 7.7.1 + semver: 7.7.2 uuid: 10.0.0 optionalDependencies: openai: 6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67) @@ -10999,6 +11184,12 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + light-my-request@6.6.0: + dependencies: + cookie: 1.0.2 + process-warning: 4.0.1 + set-cookie-parser: 2.7.1 + lighthouse-logger@2.0.2: dependencies: debug: 4.4.1 @@ -11665,6 +11856,10 @@ snapshots: ml-xsadd@3.0.1: {} + mnemonist@0.40.0: + dependencies: + obliterator: 2.0.5 + mri@1.2.0: {} ms@2.0.0: {} @@ -11773,6 +11968,8 @@ snapshots: has-symbols: 1.1.0 object-keys: 1.1.1 + obliterator@2.0.5: {} + ollama-ai-provider-v2@1.5.0(zod@3.25.67): dependencies: '@ai-sdk/provider': 2.0.0 @@ -12093,6 +12290,20 @@ snapshots: pino-std-serializers@7.0.0: {} + pino@10.1.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + pino@9.6.0: dependencies: atomic-sleep: 1.0.0 @@ -12181,6 +12392,8 @@ snapshots: process-warning@4.0.1: {} + process-warning@5.0.0: {} + process@0.11.10: {} progress@2.0.3: {} @@ -12542,6 +12755,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + ret@0.5.0: {} + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -12567,7 +12782,7 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 - retry-axios@2.6.0(axios@1.13.0): + retry-axios@2.6.0(axios@1.13.0(debug@4.4.3)): dependencies: axios: 1.13.0(debug@4.4.3) @@ -12575,6 +12790,8 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + rollup@4.40.1: dependencies: '@types/estree': 1.0.7 @@ -12672,6 +12889,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + safe-stable-stringify@1.1.1: {} safe-stable-stringify@2.5.0: {} @@ -12691,6 +12912,8 @@ snapshots: secure-json-parse@2.7.0: {} + secure-json-parse@4.1.0: {} + semver@7.7.1: {} semver@7.7.2: {} @@ -13178,6 +13401,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + toidentifier@1.0.1: {} token-types@4.2.1: