From 9c33b70ec444f0975e90ec38a5466f3248d6397b Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 21 Nov 2025 16:53:13 -0800 Subject: [PATCH 01/30] add p2p stagehand support for canonicalization rpc --- packages/core/examples/p2p-client-example.ts | 117 ++++ packages/core/examples/p2p-server-example.ts | 98 +++ packages/core/lib/v3/api.ts | 61 +- packages/core/lib/v3/index.ts | 6 + packages/core/lib/v3/server/index.ts | 622 ++++++++++++++++++ packages/core/lib/v3/server/sessions.ts | 177 +++++ packages/core/lib/v3/server/stream.ts | 148 +++++ packages/core/lib/v3/types/private/api.ts | 5 +- packages/core/lib/v3/v3.ts | 234 +++++-- packages/core/package.json | 2 + .../integration/p2p-server-client.test.ts | 425 ++++++++++++ pnpm-lock.yaml | 293 ++++++++- 12 files changed, 2080 insertions(+), 108 deletions(-) create mode 100644 packages/core/examples/p2p-client-example.ts create mode 100644 packages/core/examples/p2p-server-example.ts create mode 100644 packages/core/lib/v3/server/index.ts create mode 100644 packages/core/lib/v3/server/sessions.ts create mode 100644 packages/core/lib/v3/server/stream.ts create mode 100644 packages/core/tests/integration/p2p-server-client.test.ts 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/lib/v3/api.ts b/packages/core/lib/v3/api.ts index 13bc3892d..0b692bfdb 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); } @@ -477,30 +496,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..e0135789a 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -2,6 +2,12 @@ export { V3 } from "./v3"; export { V3 as Stagehand } from "./v3"; export * from "./types/public"; + +// 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..806f78397 --- /dev/null +++ b/packages/core/lib/v3/server/index.ts @@ -0,0 +1,622 @@ +import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import cors from "@fastify/cors"; +import { z } from "zod"; +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"; + +export interface StagehandServerOptions { + port?: number; + host?: string; + sessionTTL?: number; +} + +// Zod schemas for V3 API (we only support V3 in the library server) +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(), +}); + +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(), +}); + +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(), +}); + +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(), +}); + +/** + * 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. + */ +export class StagehandServer { + private app: FastifyInstance; + private sessionManager: SessionManager; + private port: number; + private host: string; + private isListening: boolean = false; + + constructor(options: StagehandServerOptions = {}) { + this.port = options.port || 3000; + this.host = options.host || "0.0.0.0"; + this.sessionManager = new SessionManager(options.sessionTTL); + this.app = Fastify({ + logger: false, // Disable Fastify's built-in logger for cleaner output + }); + + this.setupMiddleware(); + this.setupRoutes(); + } + + 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 { + try { + // Parse V3Options from request body + const config = request.body as V3Options; + + // Create session + const sessionId = this.sessionManager.createSession(config); + + reply.status(200).send({ + sessionId, + available: true, + }); + } catch (error) { + 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; + + if (!this.sessionManager.hasSession(sessionId)) { + return reply.status(404).send({ error: "Session not found" }); + } + + try { + // Validate request body + const data = actSchemaV3.parse(request.body); + + await createStreamingResponse>({ + sessionId, + sessionManager: this.sessionManager, + request, + reply, + 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 }; + }, + }); + } catch (error) { + 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; + + if (!this.sessionManager.hasSession(sessionId)) { + return reply.status(404).send({ error: "Session not found" }); + } + + try { + const data = extractSchemaV3.parse(request.body); + + await createStreamingResponse>({ + sessionId, + sessionManager: this.sessionManager, + request, + reply, + 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 }; + }, + }); + } catch (error) { + 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; + + if (!this.sessionManager.hasSession(sessionId)) { + return reply.status(404).send({ error: "Session not found" }); + } + + try { + const data = observeSchemaV3.parse(request.body); + + await createStreamingResponse>({ + sessionId, + sessionManager: this.sessionManager, + request, + reply, + 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 }; + }, + }); + } catch (error) { + 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; + + if (!this.sessionManager.hasSession(sessionId)) { + return reply.status(404).send({ error: "Session not found" }); + } + + try { + const data = agentExecuteSchemaV3.parse(request.body); + + await createStreamingResponse>({ + sessionId, + sessionManager: this.sessionManager, + request, + reply, + 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 }; + }, + }); + } catch (error) { + 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; + + if (!this.sessionManager.hasSession(sessionId)) { + return reply.status(404).send({ error: "Session not found" }); + } + + try { + const body = request.body as { url: string; options?: any; frameId?: string }; + + if (!body.url) { + return reply.status(400).send({ error: "url is required" }); + } + + await createStreamingResponse({ + sessionId, + sessionManager: this.sessionManager, + request, + reply, + handler: async (ctx) => { + const { stagehand } = ctx; + + // Get the page + const page = body.frameId + ? stagehand.context.resolvePageByMainFrameId(body.frameId) + : await stagehand.context.awaitActivePage(); + + if (!page) { + throw new Error("Page not found"); + } + + // Navigate to the URL + const response = await page.goto(body.url, body.options); + + return { result: response }; + }, + }); + } catch (error) { + 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; + + try { + await this.sessionManager.endSession(sessionId); + reply.status(200).send({ success: true }); + } catch (error) { + 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; + 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 { + 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/sessions.ts b/packages/core/lib/v3/server/sessions.ts new file mode 100644 index 000000000..28eecd81c --- /dev/null +++ b/packages/core/lib/v3/server/sessions.ts @@ -0,0 +1,177 @@ +import type { V3 } from "../v3"; +import type { V3Options, LogLine } from "../types/public"; +import { randomUUID } from "crypto"; + +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; + + constructor(ttlMs: number = 30_000) { + this.sessions = new Map(); + this.ttlMs = ttlMs; + 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(), + }); + + 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}`); + } + + // 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(); + } 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): 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); + } + + /** + * 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); + } + } + + /** + * 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..9c3565501 --- /dev/null +++ b/packages/core/lib/v3/server/stream.ts @@ -0,0 +1,148 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { randomUUID } from "crypto"; +import type { V3 } from "../v3"; +import type { SessionManager } from "./sessions"; + +export interface StreamingHandlerResult { + result: unknown; +} + +export interface StreamingHandlerContext { + stagehand: V3; + sessionId: string; + request: FastifyRequest; +} + +export interface StreamingResponseOptions { + sessionId: string; + sessionManager: SessionManager; + request: FastifyRequest; + reply: FastifyReply; + handler: (ctx: StreamingHandlerContext, data: T) => Promise; +} + +/** + * Sends an SSE (Server-Sent Events) message to the client + */ +function sendSSE(reply: FastifyReply, data: object): void { + const message = { + id: randomUUID(), + ...data, + }; + reply.raw.write(`data: ${JSON.stringify(message)}\n\n`); +} + +/** + * Creates a streaming response handler that sends events via SSE + * Ported from cloud API but without DB/LaunchDarkly dependencies + */ +export async function createStreamingResponse({ + sessionId, + sessionManager, + request, + reply, + handler, +}: StreamingResponseOptions): Promise { + // 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", + }); + + sendSSE(reply, { + type: "system", + data: { status: "starting" }, + }); + } + + let result: StreamingHandlerResult | null = null; + let handlerError: Error | null = null; + + try { + // Get or create the Stagehand instance with dynamic logger + const stagehand = await sessionManager.getStagehand( + sessionId, + shouldStream + ? (message) => { + sendSSE(reply, { + type: "log", + data: { + status: "running", + message, + }, + }); + } + : undefined, + ); + + if (shouldStream) { + sendSSE(reply, { + type: "system", + data: { status: "connected" }, + }); + } + + // Execute the handler + const ctx: StreamingHandlerContext = { + stagehand, + sessionId, + request, + }; + + result = await handler(ctx, data); + } catch (err) { + handlerError = err instanceof Error ? err : new Error("Unknown error occurred"); + } + + // Handle error case + if (handlerError) { + const errorMessage = handlerError.message || "An unexpected error occurred"; + + if (shouldStream) { + sendSSE(reply, { + type: "system", + data: { + status: "error", + error: errorMessage, + }, + }); + reply.raw.end(); + } else { + reply.status(500).send({ + error: errorMessage, + }); + } + return; + } + + // Handle success case + if (shouldStream) { + sendSSE(reply, { + type: "system", + data: { + status: "finished", + result: result?.result, + }, + }); + 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/v3.ts b/packages/core/lib/v3/v3.ts index 71d84d17b..e311f638a 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -961,6 +961,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; @@ -971,21 +991,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", @@ -1048,12 +1059,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", @@ -1108,10 +1114,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; @@ -1145,7 +1147,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 = { @@ -1156,19 +1168,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; }); } @@ -1187,10 +1188,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; @@ -1201,7 +1198,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 = { @@ -1212,17 +1229,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( @@ -1237,6 +1244,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") { @@ -1817,6 +1842,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/package.json b/packages/core/package.json index 0152611f9..6613a09b5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,11 +46,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/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: From 6923a4d1fc216a85be8ad1da8775e5213497d2c5 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 21 Nov 2025 17:48:04 -0800 Subject: [PATCH 02/30] tweaks to fix sse for python clients --- .../core/examples/python-client-example.py | 127 +++++ packages/core/examples/stagehand.py | 432 ++++++++++++++++++ packages/core/lib/v3/server/index.ts | 19 +- 3 files changed, 568 insertions(+), 10 deletions(-) create mode 100644 packages/core/examples/python-client-example.py create mode 100644 packages/core/examples/stagehand.py 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..9b690c8d5 --- /dev/null +++ b/packages/core/examples/stagehand.py @@ -0,0 +1,432 @@ +""" +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 httpx-sse + +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 +from httpx_sse import aconnect_sse + + +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 + + result = await self._execute( + method="act", + args={ + "input": input_data, + "options": options, + "frameId": frame_id, + } + ) + + 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 + """ + return await self._execute( + method="extract", + args={ + "instruction": instruction, + "schema": schema, + "options": options, + "frameId": frame_id, + } + ) + + 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 + """ + result = await self._execute( + method="observe", + args={ + "instruction": instruction, + "options": options, + "frameId": frame_id, + } + ) + + 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 to avoid connection reuse issues with SSE + async with httpx.AsyncClient(timeout=self.timeout) as client: + try: + async with aconnect_sse( + client, + "POST", + url, + json=args, + headers={"x-stream-response": "true"}, + ) as event_source: + result = None + + async for sse in event_source.aiter_sse(): + try: + event = json.loads(sse.data) + 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: + raise StagehandAPIError( + f"HTTP {e.response.status_code}: {e.response.text}" + ) + 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/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 806f78397..5e98c7193 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -516,23 +516,22 @@ export class StagehandServer { } try { - const body = request.body as { url: string; options?: any; frameId?: string }; - - if (!body.url) { - return reply.status(400).send({ error: "url is required" }); - } - await createStreamingResponse({ sessionId, sessionManager: this.sessionManager, request, reply, - handler: async (ctx) => { + 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 = body.frameId - ? stagehand.context.resolvePageByMainFrameId(body.frameId) + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) : await stagehand.context.awaitActivePage(); if (!page) { @@ -540,7 +539,7 @@ export class StagehandServer { } // Navigate to the URL - const response = await page.goto(body.url, body.options); + const response = await page.goto(url, options); return { result: response }; }, From f97e678686870f3722f52b4a299266966d7584f8 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 24 Nov 2025 16:43:12 -0800 Subject: [PATCH 03/30] fix python client --- packages/core/examples/stagehand.py | 89 +++++++++++++++++------------ 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/packages/core/examples/stagehand.py b/packages/core/examples/stagehand.py index 9b690c8d5..346cbe43e 100644 --- a/packages/core/examples/stagehand.py +++ b/packages/core/examples/stagehand.py @@ -5,7 +5,7 @@ Connects to a remote Stagehand server (Node.js) and executes browser automation tasks. Dependencies: - pip install httpx httpx-sse + pip install httpx Usage: from stagehand import Stagehand @@ -24,7 +24,6 @@ async def main(): import json from typing import Any, Dict, List, Optional, Union import httpx -from httpx_sse import aconnect_sse class StagehandError(Exception): @@ -186,14 +185,14 @@ async def act( """ input_data = instruction.to_dict() if isinstance(instruction, Action) else instruction - result = await self._execute( - method="act", - args={ - "input": input_data, - "options": options, - "frameId": frame_id, - } - ) + # 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) @@ -216,15 +215,18 @@ async def extract( Returns: Extracted data matching the schema (if provided) or default extraction """ - return await self._execute( - method="extract", - args={ - "instruction": instruction, - "schema": schema, - "options": options, - "frameId": frame_id, - } - ) + # 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, @@ -243,14 +245,16 @@ async def observe( Returns: List of Action objects representing possible actions """ - result = await self._execute( - method="observe", - args={ - "instruction": instruction, - "options": options, - "frameId": frame_id, - } - ) + # 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] @@ -319,21 +323,28 @@ async def _execute(self, method: str, args: Dict[str, Any]) -> Any: url = f"{self.server_url}/v1/sessions/{self.session_id}/{method}" - # Create a new client for each request to avoid connection reuse issues with SSE - async with httpx.AsyncClient(timeout=self.timeout) as client: + # 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 aconnect_sse( - client, + async with client.stream( "POST", url, json=args, headers={"x-stream-response": "true"}, - ) as event_source: + ) as response: + response.raise_for_status() + result = None - async for sse in event_source.aiter_sse(): + 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(sse.data) + event = json.loads(data_str) except json.JSONDecodeError: continue @@ -367,9 +378,13 @@ async def _execute(self, method: str, args: Dict[str, Any]) -> Any: return result except httpx.HTTPStatusError as e: - raise StagehandAPIError( - f"HTTP {e.response.status_code}: {e.response.text}" - ) + 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}") From 06506063653b253452de7cf88a85fd2655da4ab7 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 24 Nov 2025 17:06:55 -0800 Subject: [PATCH 04/30] centralize v3 stagehand-api schema and generate openapi spec from zod shapes --- packages/core/lib/v3/server/index.ts | 100 +-- packages/core/lib/v3/server/schemas.ts | 109 ++++ packages/core/openapi.yaml | 711 ++++++++++++++++++++++ packages/core/scripts/generate-openapi.ts | 280 +++++++++ 4 files changed, 1107 insertions(+), 93 deletions(-) create mode 100644 packages/core/lib/v3/server/schemas.ts create mode 100644 packages/core/openapi.yaml create mode 100644 packages/core/scripts/generate-openapi.ts diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 5e98c7193..fa0bb377b 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -14,6 +14,13 @@ import type { import type { StagehandZodSchema } from "../zodCompat"; import { SessionManager } from "./sessions"; import { createStreamingResponse } from "./stream"; +import { + actSchemaV3, + extractSchemaV3, + observeSchemaV3, + agentExecuteSchemaV3, + navigateSchemaV3, +} from "./schemas"; export interface StagehandServerOptions { port?: number; @@ -21,99 +28,6 @@ export interface StagehandServerOptions { sessionTTL?: number; } -// Zod schemas for V3 API (we only support V3 in the library server) -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(), -}); - -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(), -}); - -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(), -}); - -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(), -}); - /** * StagehandServer - Embedded API server for peer-to-peer Stagehand communication * 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/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/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}`); From e72a8883aa7405564b2acf7f00991dfda0a6024a Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 25 Nov 2025 13:37:47 -0800 Subject: [PATCH 05/30] use eventBus for LLM request and responses as well --- packages/core/lib/inference.ts | 39 +- packages/core/lib/v3/index.ts | 4 + packages/core/lib/v3/server/index.ts | 385 ++++++++++++++++++- packages/core/lib/v3/server/sessions.ts | 40 +- packages/core/lib/v3/server/stream.ts | 207 ++++++++-- packages/core/lib/v3/types/public/options.ts | 3 + packages/core/lib/v3/v3.ts | 24 ++ packages/core/package.json | 14 +- 8 files changed, 670 insertions(+), 46 deletions(-) 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/index.ts b/packages/core/lib/v3/index.ts index e0135789a..e924587ab 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -3,6 +3,10 @@ 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"; diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index fa0bb377b..7ec174270 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -1,6 +1,7 @@ import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import cors from "@fastify/cors"; import { z } from "zod"; +import { randomUUID } from "crypto"; import type { V3Options, ActOptions, @@ -21,11 +22,22 @@ import { 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; } /** @@ -33,6 +45,8 @@ export interface StagehandServerOptions { * * 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; @@ -40,11 +54,13 @@ export class StagehandServer { private port: number; private host: string; private isListening: boolean = false; + private eventBus: StagehandEventBus; - constructor(options: StagehandServerOptions = {}) { + 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.sessionManager = new SessionManager(options.sessionTTL, this.eventBus); this.app = Fastify({ logger: false, // Disable Fastify's built-in logger for cleaner output }); @@ -53,6 +69,16 @@ export class StagehandServer { 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, { @@ -130,18 +156,59 @@ export class StagehandServer { request: FastifyRequest, reply: FastifyReply, ): Promise { + const requestId = randomUUID(); + const startTime = Date.now(); + try { // Parse V3Options from request body const config = request.body as V3Options; - // Create session + // 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", }); @@ -156,8 +223,37 @@ export class StagehandServer { 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" }); } @@ -167,9 +263,12 @@ export class StagehandServer { await createStreamingResponse>({ sessionId, + requestId, + actionType: "act", sessionManager: this.sessionManager, request, reply, + eventBus: this.eventBus, handler: async (ctx, data) => { const { stagehand } = ctx; const { frameId } = data; @@ -207,7 +306,26 @@ export class StagehandServer { 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", @@ -226,8 +344,36 @@ export class StagehandServer { 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" }); } @@ -236,9 +382,12 @@ export class StagehandServer { await createStreamingResponse>({ sessionId, + requestId, + actionType: "extract", sessionManager: this.sessionManager, request, reply, + eventBus: this.eventBus, handler: async (ctx, data) => { const { stagehand } = ctx; const { frameId } = data; @@ -281,7 +430,25 @@ export class StagehandServer { 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", @@ -300,8 +467,36 @@ export class StagehandServer { 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" }); } @@ -310,9 +505,12 @@ export class StagehandServer { await createStreamingResponse>({ sessionId, + requestId, + actionType: "observe", sessionManager: this.sessionManager, request, reply, + eventBus: this.eventBus, handler: async (ctx, data) => { const { stagehand } = ctx; const { frameId } = data; @@ -349,7 +547,25 @@ export class StagehandServer { 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", @@ -368,8 +584,36 @@ export class StagehandServer { 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" }); } @@ -378,9 +622,12 @@ export class StagehandServer { 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; @@ -405,7 +652,25 @@ export class StagehandServer { 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", @@ -424,17 +689,48 @@ export class StagehandServer { 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; @@ -458,7 +754,25 @@ export class StagehandServer { 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", @@ -475,11 +789,50 @@ export class StagehandServer { 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); + 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", }); @@ -498,6 +851,21 @@ export class StagehandServer { 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); @@ -509,6 +877,15 @@ export class StagehandServer { * 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; diff --git a/packages/core/lib/v3/server/sessions.ts b/packages/core/lib/v3/server/sessions.ts index 28eecd81c..ef7fcd973 100644 --- a/packages/core/lib/v3/server/sessions.ts +++ b/packages/core/lib/v3/server/sessions.ts @@ -1,6 +1,7 @@ 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; @@ -14,10 +15,12 @@ export class SessionManager { private sessions: Map; private cleanupInterval: NodeJS.Timeout | null = null; private ttlMs: number; + private eventBus: StagehandEventBus; - constructor(ttlMs: number = 30_000) { + constructor(ttlMs: number = 30_000, eventBus: StagehandEventBus) { this.sessions = new Map(); this.ttlMs = ttlMs; + this.eventBus = eventBus; this.startCleanup(); } @@ -35,6 +38,14 @@ export class SessionManager { 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; } @@ -51,6 +62,14 @@ export class SessionManager { 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; @@ -78,6 +97,13 @@ export class SessionManager { 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; @@ -104,7 +130,7 @@ export class SessionManager { /** * End a session and cleanup */ - async endSession(sessionId: string): Promise { + async endSession(sessionId: string, reason: "manual" | "ttl_expired" | "cache_evicted" | "error" = "manual"): Promise { const entry = this.sessions.get(sessionId); if (!entry) { @@ -121,6 +147,14 @@ export class SessionManager { } this.sessions.delete(sessionId); + + // Emit session ended event (fire and forget) + void this.eventBus.emitAsync("StagehandSessionEnded", { + type: "StagehandSessionEnded", + timestamp: new Date(), + sessionId, + reason, + }); } /** @@ -157,7 +191,7 @@ export class SessionManager { // End all expired sessions for (const sessionId of expiredSessions) { console.log(`Cleaning up expired session: ${sessionId}`); - await this.endSession(sessionId); + await this.endSession(sessionId, "ttl_expired"); } } diff --git a/packages/core/lib/v3/server/stream.ts b/packages/core/lib/v3/server/stream.ts index 9c3565501..8fd45cb6b 100644 --- a/packages/core/lib/v3/server/stream.ts +++ b/packages/core/lib/v3/server/stream.ts @@ -2,6 +2,16 @@ 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; @@ -10,26 +20,48 @@ export interface StreamingHandlerResult { 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 */ -function sendSSE(reply: FastifyReply, data: object): void { +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, + }); } /** @@ -38,11 +70,16 @@ function sendSSE(reply: FastifyReply, data: object): void { */ 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"; @@ -64,49 +101,141 @@ export async function createStreamingResponse({ "Access-Control-Allow-Credentials": "true", }); - sendSSE(reply, { - type: "system", - data: { status: "starting" }, + // 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 - ? (message) => { - sendSSE(reply, { - type: "log", - data: { - status: "running", - message, + ? 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) { - sendSSE(reply, { - type: "system", - data: { status: "connected" }, - }); + 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 @@ -114,13 +243,28 @@ export async function createStreamingResponse({ const errorMessage = handlerError.message || "An unexpected error occurred"; if (shouldStream) { - sendSSE(reply, { - type: "system", - data: { - status: "error", - error: errorMessage, + 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({ @@ -132,13 +276,28 @@ export async function createStreamingResponse({ // Handle success case if (shouldStream) { - sendSSE(reply, { - type: "system", - data: { - status: "finished", - result: result?.result, + 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({ 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 e311f638a..03f2f38cc 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"; @@ -124,6 +125,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; @@ -171,6 +173,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, @@ -201,6 +204,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; @@ -305,6 +311,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); @@ -1322,6 +1336,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; diff --git a/packages/core/package.json b/packages/core/package.json index 6613a09b5..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", From dc25e8b2cea4a08f7c3b172c629cb36a4247cbad Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 1 Dec 2025 14:25:58 -0800 Subject: [PATCH 06/30] use event bus for llm events --- packages/core/lib/inference.ts | 2 +- packages/core/lib/v3/eventBus.ts | 64 +++ packages/core/lib/v3/handlers/actHandler.ts | 7 + .../core/lib/v3/handlers/extractHandler.ts | 5 + .../core/lib/v3/handlers/observeHandler.ts | 5 + packages/core/lib/v3/llm/llmEventBridge.ts | 123 ++++++ packages/core/lib/v3/llm/llmEventHandler.ts | 149 +++++++ .../v3/server/CLOUD_INTEGRATION_EXAMPLE.md | 415 ++++++++++++++++++ packages/core/lib/v3/server/events.ts | 305 +++++++++++++ packages/core/lib/v3/server/index.ts | 13 +- packages/core/lib/v3/v3.ts | 3 + packages/core/package.json | 2 + 12 files changed, 1086 insertions(+), 7 deletions(-) create mode 100644 packages/core/lib/v3/eventBus.ts create mode 100644 packages/core/lib/v3/llm/llmEventBridge.ts create mode 100644 packages/core/lib/v3/llm/llmEventHandler.ts create mode 100644 packages/core/lib/v3/server/CLOUD_INTEGRATION_EXAMPLE.md create mode 100644 packages/core/lib/v3/server/events.ts diff --git a/packages/core/lib/inference.ts b/packages/core/lib/inference.ts index 84ccf100c..978526757 100644 --- a/packages/core/lib/inference.ts +++ b/packages/core/lib/inference.ts @@ -435,7 +435,7 @@ export async function act({ } const start = Date.now(); - const rawResponse = await llmClient.createChatCompletion({ + const rawResponse = await createChatCompletionViaEventBus(eventBus, { options: { messages, response_model: { diff --git a/packages/core/lib/v3/eventBus.ts b/packages/core/lib/v3/eventBus.ts new file mode 100644 index 000000000..f8a41d899 --- /dev/null +++ b/packages/core/lib/v3/eventBus.ts @@ -0,0 +1,64 @@ +/** + * Central Event Bus for Stagehand + * + * Single event emitter shared by: + * - V3 class (LLM events, library events) + * - StagehandServer (server lifecycle, request/response events) + * - External listeners (cloud servers, monitoring, etc.) + */ + +import { EventEmitter } from "events"; +import type { StagehandServerEventMap } from "./server/events"; + +/** + * Type-safe event bus for all Stagehand events + */ +export class StagehandEventBus extends EventEmitter { + /** + * Emit an event and wait for all async listeners to complete + */ + async emitAsync( + event: K, + data: StagehandServerEventMap[K], + ): Promise { + const listeners = this.listeners(event); + await Promise.all(listeners.map((listener) => listener(data))); + } + + /** + * Type-safe event listener + */ + on( + event: K, + listener: (data: StagehandServerEventMap[K]) => void | Promise, + ): this { + return super.on(event, listener); + } + + /** + * Type-safe one-time event listener + */ + once( + event: K, + listener: (data: StagehandServerEventMap[K]) => void | Promise, + ): this { + return super.once(event, listener); + } + + /** + * Type-safe remove listener + */ + off( + event: K, + listener: (data: StagehandServerEventMap[K]) => void | Promise, + ): this { + return super.off(event, listener); + } +} + +/** + * Create a new event bus instance + */ +export function createEventBus(): StagehandEventBus { + return new StagehandEventBus(); +} diff --git a/packages/core/lib/v3/handlers/actHandler.ts b/packages/core/lib/v3/handlers/actHandler.ts index 2527064bf..9bd14568c 100644 --- a/packages/core/lib/v3/handlers/actHandler.ts +++ b/packages/core/lib/v3/handlers/actHandler.ts @@ -22,6 +22,7 @@ import { performUnderstudyMethod, waitForDomNetworkQuiet, } from "./handlerUtils/actHandlerUtils"; +import type { StagehandEventBus } from "../eventBus"; export class ActHandler { private readonly llmClient: LLMClient; @@ -40,12 +41,14 @@ export class ActHandler { inferenceTimeMs: number, ) => void; private readonly defaultDomSettleTimeoutMs?: number; + private readonly eventBus: StagehandEventBus; constructor( llmClient: LLMClient, defaultModelName: AvailableModel, defaultClientOptions: ClientOptions, resolveLlmClient: (model?: ModelConfiguration) => LLMClient, + eventBus: StagehandEventBus, systemPrompt?: string, logInferenceToFile?: boolean, selfHeal?: boolean, @@ -63,6 +66,7 @@ export class ActHandler { this.defaultModelName = defaultModelName; this.defaultClientOptions = defaultClientOptions; this.resolveLlmClient = resolveLlmClient; + this.eventBus = eventBus; this.systemPrompt = systemPrompt ?? ""; this.logInferenceToFile = logInferenceToFile ?? false; this.selfHeal = !!selfHeal; @@ -100,6 +104,7 @@ export class ActHandler { instruction: observeActInstruction, domElements: combinedTree, llmClient, + eventBus: this.eventBus, userProvidedInstructions: this.systemPrompt, logger: v3Logger, logInferenceToFile: this.logInferenceToFile, @@ -230,6 +235,7 @@ export class ActHandler { instruction: stepTwoInstructions, domElements: diffedTree, llmClient, + eventBus: this.eventBus, userProvidedInstructions: this.systemPrompt, logger: v3Logger, logInferenceToFile: this.logInferenceToFile, @@ -422,6 +428,7 @@ export class ActHandler { instruction, domElements: combinedTree, llmClient: effectiveClient, + eventBus: this.eventBus, userProvidedInstructions: this.systemPrompt, logger: v3Logger, logInferenceToFile: this.logInferenceToFile, diff --git a/packages/core/lib/v3/handlers/extractHandler.ts b/packages/core/lib/v3/handlers/extractHandler.ts index 9def5154d..7ed6ba63e 100644 --- a/packages/core/lib/v3/handlers/extractHandler.ts +++ b/packages/core/lib/v3/handlers/extractHandler.ts @@ -25,6 +25,7 @@ import type { StagehandZodObject, StagehandZodSchema, } from "../zodCompat"; +import type { StagehandEventBus } from "../eventBus"; /** * Scans the provided Zod schema for any `z.string().url()` fields and @@ -60,6 +61,7 @@ export class ExtractHandler { private readonly defaultModelName: AvailableModel; private readonly defaultClientOptions: ClientOptions; private readonly resolveLlmClient: (model?: ModelConfiguration) => LLMClient; + private readonly eventBus: StagehandEventBus; private readonly systemPrompt: string; private readonly logInferenceToFile: boolean; private readonly experimental: boolean; @@ -77,6 +79,7 @@ export class ExtractHandler { defaultModelName: AvailableModel, defaultClientOptions: ClientOptions, resolveLlmClient: (model?: ModelConfiguration) => LLMClient, + eventBus: StagehandEventBus, systemPrompt?: string, logInferenceToFile?: boolean, experimental?: boolean, @@ -93,6 +96,7 @@ export class ExtractHandler { this.defaultModelName = defaultModelName; this.defaultClientOptions = defaultClientOptions; this.resolveLlmClient = resolveLlmClient; + this.eventBus = eventBus; this.systemPrompt = systemPrompt ?? ""; this.logInferenceToFile = logInferenceToFile ?? false; this.experimental = experimental ?? false; @@ -171,6 +175,7 @@ export class ExtractHandler { domElements: combinedTree, schema: transformedSchema as StagehandZodObject, llmClient, + eventBus: this.eventBus, userProvidedInstructions: this.systemPrompt, logger: v3Logger, logInferenceToFile: this.logInferenceToFile, diff --git a/packages/core/lib/v3/handlers/observeHandler.ts b/packages/core/lib/v3/handlers/observeHandler.ts index e4e28c1f3..4519171d1 100644 --- a/packages/core/lib/v3/handlers/observeHandler.ts +++ b/packages/core/lib/v3/handlers/observeHandler.ts @@ -13,12 +13,14 @@ import { ClientOptions, ModelConfiguration, } from "../types/public/model"; +import type { StagehandEventBus } from "../eventBus"; export class ObserveHandler { private readonly llmClient: LLMClient; private readonly defaultModelName: AvailableModel; private readonly defaultClientOptions: ClientOptions; private readonly resolveLlmClient: (model?: ModelConfiguration) => LLMClient; + private readonly eventBus: StagehandEventBus; private readonly systemPrompt: string; private readonly logInferenceToFile: boolean; private readonly experimental: boolean; @@ -36,6 +38,7 @@ export class ObserveHandler { defaultModelName: AvailableModel, defaultClientOptions: ClientOptions, resolveLlmClient: (model?: ModelConfiguration) => LLMClient, + eventBus: StagehandEventBus, systemPrompt?: string, logInferenceToFile?: boolean, experimental?: boolean, @@ -52,6 +55,7 @@ export class ObserveHandler { this.defaultModelName = defaultModelName; this.defaultClientOptions = defaultClientOptions; this.resolveLlmClient = resolveLlmClient; + this.eventBus = eventBus; this.systemPrompt = systemPrompt ?? ""; this.logInferenceToFile = logInferenceToFile ?? false; this.experimental = experimental ?? false; @@ -101,6 +105,7 @@ export class ObserveHandler { instruction: effectiveInstruction, domElements: combinedTree, llmClient, + eventBus: this.eventBus, userProvidedInstructions: this.systemPrompt, logger: v3Logger, logInferenceToFile: this.logInferenceToFile, diff --git a/packages/core/lib/v3/llm/llmEventBridge.ts b/packages/core/lib/v3/llm/llmEventBridge.ts new file mode 100644 index 000000000..efe9427b1 --- /dev/null +++ b/packages/core/lib/v3/llm/llmEventBridge.ts @@ -0,0 +1,123 @@ +/** + * LLM Event Bridge - Routes LLM requests through the event bus + * + * This module provides a bridge between code that needs LLM responses + * and the actual LLM implementations. It uses the event bus to allow + * remote execution of LLM calls. + */ + +import { randomUUID } from "crypto"; +import type { StagehandEventBus } from "../eventBus"; +import type { + ChatCompletionOptions, + CreateChatCompletionOptions, + LLMParsedResponse, + LLMResponse, +} from "./LLMClient"; +import type { LogLine } from "../types/public"; + +/** + * Make an LLM request via the event bus and wait for a response + * + * This function emits a StagehandLLMRequest event and waits for a + * StagehandLLMResponse event with the same requestId. + * + * Returns the same structure as llmClient.createChatCompletion: { data: T, usage?: LLMUsage } + */ +export async function createChatCompletionViaEventBus( + eventBus: StagehandEventBus, + options: CreateChatCompletionOptions, + sessionId?: string, +): Promise> { + const requestId = randomUUID(); + const startTime = Date.now(); + + // Create a promise that will resolve when we get the response + const responsePromise = new Promise>((resolve, reject) => { + // Set up a one-time listener for the response + const responseHandler = (data: any) => { + // Only handle responses for this specific request + if (data.requestId === requestId) { + // Remove the listener + eventBus.off("StagehandLLMResponse", responseHandler); + eventBus.off("StagehandLLMError", errorHandler); + + // Check if there was an error + if (data.error) { + reject(new Error(data.error.message)); + } else { + // Return the same structure as llmClient.createChatCompletion + if (data.parsedResponse) { + resolve(data.parsedResponse as LLMParsedResponse); + } else { + resolve({ data: data.rawResponse as T, usage: data.usage }); + } + } + } + }; + + const errorHandler = (data: any) => { + if (data.requestId === requestId) { + eventBus.off("StagehandLLMResponse", responseHandler); + eventBus.off("StagehandLLMError", errorHandler); + reject(new Error(data.error.message)); + } + }; + + // Listen for both response and error events + eventBus.on("StagehandLLMResponse", responseHandler); + eventBus.on("StagehandLLMError", errorHandler); + + // Set a timeout to prevent hanging forever + setTimeout(() => { + eventBus.off("StagehandLLMResponse", responseHandler); + eventBus.off("StagehandLLMError", errorHandler); + reject(new Error("LLM request timeout after 5 minutes")); + }, 5 * 60 * 1000); // 5 minute timeout + }); + + // Emit the request event + await eventBus.emitAsync("StagehandLLMRequest", { + type: "StagehandLLMRequest", + timestamp: new Date(), + requestId, + sessionId, + modelName: options.options.messages[0]?.role ? "unknown" : "unknown", // Will be set by handler + temperature: options.options.temperature, + maxTokens: options.options.maxOutputTokens, + messages: options.options.messages.map((msg) => ({ + role: msg.role, + content: + typeof msg.content === "string" + ? msg.content + : msg.content.map((c) => ({ + type: c.type, + text: c.text, + image: (c as any).image_url?.url || (c as any).source?.data, + })), + })), + tools: options.options.tools?.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters as Record, + })), + schema: options.options.response_model?.schema + ? (options.options.response_model.schema as any) + : undefined, + requestType: undefined, // Will be determined by context + }); + + // Wait for and return the response + return responsePromise; +} + +/** + * Type guard to check if options include a response_model + */ +export function hasResponseModel( + options: CreateChatCompletionOptions, +): options is CreateChatCompletionOptions & { + options: { response_model: { name: string; schema: any } }; +} { + return !!options.options.response_model; +} diff --git a/packages/core/lib/v3/llm/llmEventHandler.ts b/packages/core/lib/v3/llm/llmEventHandler.ts new file mode 100644 index 000000000..fb7e766bf --- /dev/null +++ b/packages/core/lib/v3/llm/llmEventHandler.ts @@ -0,0 +1,149 @@ +/** + * LLM Event Handler - Listens for LLM requests and executes them + * + * This module listens for StagehandLLMRequest events on the event bus, + * calls the actual LLM implementation, and emits StagehandLLMResponse events. + */ + +import type { StagehandEventBus } from "../eventBus"; +import type { LLMClient, CreateChatCompletionOptions } from "./LLMClient"; +import type { LogLine } from "../types/public"; + +export interface LLMEventHandlerOptions { + eventBus: StagehandEventBus; + llmClient: LLMClient; + logger: (message: LogLine) => void; +} + +/** + * Initialize the LLM event handler + * + * This sets up a listener on the event bus that will handle LLM requests + * by calling the provided LLMClient and emitting responses. + * + * @returns A cleanup function to remove the listener + */ +export function initializeLLMEventHandler({ + eventBus, + llmClient, + logger, +}: LLMEventHandlerOptions): () => void { + const handleLLMRequest = async (event: any) => { + const { requestId, messages, tools, schema, temperature, maxTokens } = + event; + + try { + // Build the options for createChatCompletion + const options: CreateChatCompletionOptions = { + options: { + messages: messages.map((msg: any) => ({ + role: msg.role, + content: + typeof msg.content === "string" + ? msg.content + : msg.content.map((c: any) => { + if (c.type === "text") { + return { type: "text", text: c.text }; + } else if (c.type === "image_url" || c.image) { + return { + type: "image_url", + image_url: { url: c.image }, + }; + } + return c; + }), + })), + temperature, + maxOutputTokens: maxTokens, + tools, + requestId, + }, + logger, + }; + + // Add response_model if schema is provided + if (schema) { + options.options.response_model = { + name: "Response", + schema, + }; + } + + const startTime = Date.now(); + let response: any; + let parsedResponse: any = null; + + // Call the LLM + if (schema) { + // Structured response + const result = await llmClient.createChatCompletion(options as any); + parsedResponse = result; + response = null; + } else { + // Raw response + response = await llmClient.createChatCompletion(options); + } + + const inferenceTimeMs = Date.now() - startTime; + + // Extract usage information + let usage: any = undefined; + if (parsedResponse?.usage) { + usage = { + promptTokens: parsedResponse.usage.prompt_tokens, + completionTokens: parsedResponse.usage.completion_tokens, + totalTokens: parsedResponse.usage.total_tokens, + }; + } else if (response?.usage) { + usage = { + promptTokens: response.usage.prompt_tokens, + completionTokens: response.usage.completion_tokens, + totalTokens: response.usage.total_tokens, + }; + } + + // Emit success response + await eventBus.emitAsync("StagehandLLMResponse", { + type: "StagehandLLMResponse", + timestamp: new Date(), + requestId, + sessionId: event.sessionId, + content: parsedResponse + ? JSON.stringify(parsedResponse.data || parsedResponse) + : response?.choices?.[0]?.message?.content || "", + toolCalls: response?.choices?.[0]?.message?.tool_calls?.map( + (tc: any) => ({ + id: tc.id, + name: tc.function.name, + arguments: JSON.parse(tc.function.arguments), + }), + ), + finishReason: response?.choices?.[0]?.finish_reason || "stop", + usage, + rawResponse: response, + parsedResponse, + }); + } catch (error) { + // Emit error response + await eventBus.emitAsync("StagehandLLMError", { + type: "StagehandLLMError", + timestamp: new Date(), + requestId, + sessionId: event.sessionId, + error: { + message: error instanceof Error ? error.message : "Unknown error", + code: (error as any).code, + stack: error instanceof Error ? error.stack : undefined, + }, + }); + } + }; + + // Register the handler + eventBus.on("StagehandLLMRequest", handleLLMRequest); + + // Return cleanup function + return () => { + eventBus.off("StagehandLLMRequest", handleLLMRequest); + }; +} diff --git a/packages/core/lib/v3/server/CLOUD_INTEGRATION_EXAMPLE.md b/packages/core/lib/v3/server/CLOUD_INTEGRATION_EXAMPLE.md new file mode 100644 index 000000000..6935ba88b --- /dev/null +++ b/packages/core/lib/v3/server/CLOUD_INTEGRATION_EXAMPLE.md @@ -0,0 +1,415 @@ +# Cloud Server Integration with Event-Based Architecture + +This document shows how the cloud stagehand-api server can use the library's StagehandServer with event listeners to add cloud-only logic (database, LaunchDarkly, Sentry, etc.). + +## Architecture Overview + +The library `StagehandServer` emits 21 different event types at key lifecycle points. The cloud server can instantiate this server and add event listeners to hook into these events for cloud-specific operations. + +## Event Types Available + +### Server Lifecycle +- `StagehandServerStarted` - Server begins listening +- `StagehandServerReady` - Server fully initialized +- `StagehandServerShutdown` - Server is shutting down + +### Session Lifecycle +- `StagehandSessionCreated` - New session created +- `StagehandSessionResumed` - Session retrieved (cache hit/miss) +- `StagehandSessionInitialized` - Stagehand instance initialized +- `StagehandSessionEnded` - Session cleanup complete + +### Request Lifecycle +- `StagehandRequestReceived` - HTTP request received +- `StagehandRequestValidated` - Request body validated +- `StagehandRequestCompleted` - Response sent to client + +### Action Lifecycle +- `StagehandActionStarted` - act/extract/observe/agentExecute/navigate begins +- `StagehandActionProgress` - Log message during execution +- `StagehandActionCompleted` - Action finished successfully +- `StagehandActionErrored` - Action failed with error + +### Streaming Events +- `StagehandStreamStarted` - SSE connection opened +- `StagehandStreamMessageSent` - SSE message sent +- `StagehandStreamEnded` - SSE connection closed + +### Cache Events +- `StagehandCacheHit` - Session found in cache +- `StagehandCacheMissed` - Session not in cache +- `StagehandCacheEvicted` - Session removed from cache + +## Example: Cloud Server Implementation + +```typescript +// core/apps/stagehand-api/src/cloud-server.ts + +import { StagehandServer } from '@browserbasehq/stagehand/server'; +import type { + StagehandActionStartedEvent, + StagehandActionCompletedEvent, + StagehandActionErroredEvent, + StagehandSessionCreatedEvent, +} from '@browserbasehq/stagehand/server'; +import { db } from './lib/db'; +import { + createAction, + updateActionResult, + createInference, + updateActionStartAndEndTime, + createSession as createDbSession, +} from './lib/db/actions'; +import * as Sentry from '@sentry/node'; +import { Browserbase } from '@browserbase/sdk'; +import { LaunchDarklyClient } from './lib/launchdarkly'; + +export class CloudStagehandServer { + private stagehandServer: StagehandServer; + private launchdarkly: LaunchDarklyClient; + private browserbase: Browserbase; + + constructor() { + // Instantiate the library server + this.stagehandServer = new StagehandServer({ + port: 3000, + host: '0.0.0.0', + sessionTTL: 300000, // 5 minutes + }); + + this.launchdarkly = new LaunchDarklyClient(); + this.browserbase = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY! }); + + // Register all cloud-specific event listeners + this.registerEventListeners(); + } + + private registerEventListeners() { + // ===== SERVER LIFECYCLE ===== + + this.stagehandServer.on('StagehandServerReady', async (event) => { + console.log(`Cloud Stagehand API ready on port ${event.port}`); + // Initialize LaunchDarkly, connect to DB, etc. + await this.launchdarkly.initialize(); + }); + + this.stagehandServer.on('StagehandServerShutdown', async (event) => { + console.log('Shutting down cloud services...'); + await this.launchdarkly.close(); + // Cleanup cloud resources + }); + + // ===== SESSION LIFECYCLE ===== + + this.stagehandServer.on('StagehandSessionCreated', async (event: StagehandSessionCreatedEvent) => { + console.log(`Session created: ${event.sessionId}`); + + // Check LaunchDarkly rollout + const isAvailable = await this.launchdarkly.getFlagValue( + 'stagehand-api-ga-rollout', + { key: event.sessionId }, + false + ); + + if (!isAvailable) { + throw new Error('API not available for this user'); + } + + // Create Browserbase session + const bbSession = await this.browserbase.sessions.create({ + projectId: event.config.browserbase?.projectId, + keepAlive: true, + userMetadata: { stagehand: 'true' }, + }); + + // Store in database + await createDbSession({ + id: bbSession.id, + browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, + browserbaseProjectId: event.config.browserbase?.projectId!, + modelName: event.config.model?.modelName || 'gpt-4o', + domSettleTimeoutMs: event.config.domSettleTimeoutMs, + verbose: event.config.verbose, + systemPrompt: event.config.systemPrompt, + selfHeal: event.config.selfHeal, + }); + }); + + // ===== REQUEST LIFECYCLE ===== + + this.stagehandServer.on('StagehandRequestReceived', async (event) => { + console.log(`Request received: ${event.method} ${event.path}`); + + // Authenticate (if needed) + const apiKey = event.headers['x-bb-api-key']; + if (apiKey && !await this.validateApiKey(apiKey)) { + throw new Error('Invalid API key'); + } + + // Check if session exists in DB + if (event.sessionId) { + const session = await db.sessions.findById(event.sessionId); + if (!session) { + throw new Error('Session not found in database'); + } + } + }); + + // ===== ACTION LIFECYCLE ===== + + this.stagehandServer.on('StagehandActionStarted', async (event: StagehandActionStartedEvent) => { + console.log(`Action started: ${event.actionType} for session ${event.sessionId}`); + + // Create action record in database + const action = await createAction({ + sessionId: event.sessionId, + method: event.actionType, + xpath: '', + options: event.options, + url: event.url, + }); + + // IMPORTANT: Set actionId on event so other listeners can use it + event.actionId = action.id; + }); + + this.stagehandServer.on('StagehandActionCompleted', async (event: StagehandActionCompletedEvent) => { + console.log(`Action completed: ${event.actionType} (${event.durationMs}ms)`); + + if (!event.actionId) { + console.warn('No actionId found on ActionCompleted event'); + return; + } + + // Update action result in database + await updateActionResult(event.actionId, event.result); + + // Log token usage metrics + if (event.metrics) { + await createInference(event.actionId, { + inputTokens: event.metrics.promptTokens, + outputTokens: event.metrics.completionTokens, + timeMs: event.metrics.inferenceTimeMs, + }); + } + + // Update timing + const sentAt = new Date(Date.now() - event.durationMs); + await updateActionStartAndEndTime(event.actionId, sentAt, new Date()); + }); + + this.stagehandServer.on('StagehandActionErrored', async (event: StagehandActionErroredEvent) => { + console.error(`Action failed: ${event.actionType} - ${event.error.message}`); + + // Send error to Sentry + Sentry.captureException(new Error(event.error.message), { + tags: { + sessionId: event.sessionId, + actionType: event.actionType, + actionId: event.actionId, + }, + extra: { + stack: event.error.stack, + durationMs: event.durationMs, + }, + }); + + // Update database with error + if (event.actionId) { + await updateActionResult(event.actionId, { + error: event.error.message, + success: false, + }); + } + }); + + // ===== PROGRESS LOGGING ===== + + this.stagehandServer.on('StagehandActionProgress', async (event) => { + // Could stream to external logging service (DataDog, CloudWatch, etc.) + // console.log(`[${event.sessionId}] ${event.message.message}`); + }); + } + + private async validateApiKey(apiKey: string): Promise { + // Implement API key validation + return apiKey.startsWith('bb_'); + } + + async start() { + await this.stagehandServer.listen(); + } + + async stop() { + await this.stagehandServer.close(); + } + + // Expose the underlying server for direct access if needed + getServer(): StagehandServer { + return this.stagehandServer; + } +} + +// Usage +const cloudServer = new CloudStagehandServer(); +await cloudServer.start(); +``` + +## Key Patterns + +### 1. Mutating Events for Communication + +Cloud listeners can set properties on events to communicate data back to the library: + +```typescript +stagehandServer.on('StagehandActionStarted', async (event) => { + const action = await createAction({...}); + event.actionId = action.id; // Set actionId for downstream listeners +}); +``` + +### 2. Async Listeners + +All event listeners are awaited, so you can perform async operations: + +```typescript +stagehandServer.on('StagehandSessionCreated', async (event) => { + await db.sessions.create({...}); // Waits for DB write +}); +``` + +### 3. Error Handling in Listeners + +Errors in listeners will propagate and fail the request: + +```typescript +stagehandServer.on('StagehandRequestReceived', async (event) => { + const session = await db.sessions.findById(event.sessionId); + if (!session) { + throw new Error('Session not found'); // Request fails with 500 + } +}); +``` + +### 4. Conditional Logic Based on Events + +```typescript +stagehandServer.on('StagehandSessionResumed', async (event) => { + if (event.fromCache) { + console.log('Cache hit!'); + // No need to recreate Stagehand instance + } else { + console.log('Cache miss - initializing new instance'); + // Fetch from DB, connect to Browserbase, etc. + } +}); +``` + +## Benefits of Event-Based Architecture + +1. **Zero Code Duplication**: Library contains all core logic, cloud adds hooks +2. **Clean Separation**: Cloud concerns (DB, monitoring, etc.) isolated in listeners +3. **Easy Testing**: Test library and cloud independently +4. **Flexible**: Add new cloud features by adding new listeners +5. **Version Independence**: Library and cloud can evolve separately +6. **Type Safety**: All events are fully typed with TypeScript + +## Migration Path for Existing Cloud Server + +1. **Phase 1**: Instantiate `StagehandServer` in cloud server +2. **Phase 2**: Add event listeners for all DB operations +3. **Phase 3**: Remove duplicated route handlers from cloud server +4. **Phase 4**: Delete ~2,100 lines of duplicated code +5. **Result**: Cloud server becomes pure event orchestrator (~500 lines) + +## Complete Event Listener Template + +```typescript +private registerAllEvents() { + // Server + this.stagehandServer.on('StagehandServerStarted', async (event) => {}); + this.stagehandServer.on('StagehandServerReady', async (event) => {}); + this.stagehandServer.on('StagehandServerShutdown', async (event) => {}); + + // Session + this.stagehandServer.on('StagehandSessionCreated', async (event) => {}); + this.stagehandServer.on('StagehandSessionResumed', async (event) => {}); + this.stagehandServer.on('StagehandSessionInitialized', async (event) => {}); + this.stagehandServer.on('StagehandSessionEnded', async (event) => {}); + + // Request + this.stagehandServer.on('StagehandRequestReceived', async (event) => {}); + this.stagehandServer.on('StagehandRequestValidated', async (event) => {}); + this.stagehandServer.on('StagehandRequestCompleted', async (event) => {}); + + // Action + this.stagehandServer.on('StagehandActionStarted', async (event) => {}); + this.stagehandServer.on('StagehandActionProgress', async (event) => {}); + this.stagehandServer.on('StagehandActionCompleted', async (event) => {}); + this.stagehandServer.on('StagehandActionErrored', async (event) => {}); + + // Stream + this.stagehandServer.on('StagehandStreamStarted', async (event) => {}); + this.stagehandServer.on('StagehandStreamMessageSent', async (event) => {}); + this.stagehandServer.on('StagehandStreamEnded', async (event) => {}); + + // Cache (optional - mostly for cloud with multi-session support) + this.stagehandServer.on('StagehandCacheHit', async (event) => {}); + this.stagehandServer.on('StagehandCacheMissed', async (event) => {}); + this.stagehandServer.on('StagehandCacheEvicted', async (event) => {}); +} +``` + +## Testing Event Listeners + +```typescript +import { StagehandServer } from '@browserbasehq/stagehand/server'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +describe('Cloud Server Event Listeners', () => { + let server: StagehandServer; + let events: any[] = []; + + beforeEach(async () => { + server = new StagehandServer({ port: 3001 }); + + // Capture all events + server.on('StagehandActionStarted', (event) => { + events.push(event); + }); + + await server.listen(); + }); + + afterEach(async () => { + await server.close(); + events = []; + }); + + it('should emit ActionStarted event when act is called', async () => { + // Create session and call act endpoint + // ... + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('StagehandActionStarted'); + expect(events[0].actionType).toBe('act'); + }); +}); +``` + +## Next Steps + +1. Import `StagehandServer` and event types in cloud server +2. Create `CloudStagehandServer` wrapper class +3. Register event listeners for all DB, LaunchDarkly, Sentry operations +4. Test that cloud server works with event-based architecture +5. Remove old route handler files from cloud server +6. Deploy and verify in production + +## Result + +- **Library Server**: 946 lines (pure logic, zero cloud dependencies) +- **Cloud Server**: ~500 lines (event orchestration + cloud integrations) +- **Code Reduction**: 81% (from 2,661 to 500 lines in cloud server) +- **Maintainability**: Single source of truth for all route logic +- **Flexibility**: Easy to add new cloud features via new listeners diff --git a/packages/core/lib/v3/server/events.ts b/packages/core/lib/v3/server/events.ts new file mode 100644 index 000000000..69d908e10 --- /dev/null +++ b/packages/core/lib/v3/server/events.ts @@ -0,0 +1,305 @@ +import type { V3Options, LogLine } from "../types/public"; +import type { FastifyRequest } from "fastify"; + +/** + * Base event interface - all events extend this + */ +export interface StagehandServerEvent { + timestamp: Date; + sessionId?: string; + requestId?: string; // For correlation across events +} + +// ===== SERVER LIFECYCLE EVENTS ===== + +export interface StagehandServerStartedEvent extends StagehandServerEvent { + type: "StagehandServerStarted"; + port: number; + host: string; +} + +export interface StagehandServerReadyEvent extends StagehandServerEvent { + type: "StagehandServerReady"; +} + +export interface StagehandServerShutdownEvent extends StagehandServerEvent { + type: "StagehandServerShutdown"; + graceful: boolean; +} + +// ===== SESSION LIFECYCLE EVENTS ===== + +export interface StagehandSessionCreatedEvent extends StagehandServerEvent { + type: "StagehandSessionCreated"; + sessionId: string; + config: V3Options; +} + +export interface StagehandSessionResumedEvent extends StagehandServerEvent { + type: "StagehandSessionResumed"; + sessionId: string; + fromCache: boolean; +} + +export interface StagehandSessionInitializedEvent extends StagehandServerEvent { + type: "StagehandSessionInitialized"; + sessionId: string; +} + +export interface StagehandSessionEndedEvent extends StagehandServerEvent { + type: "StagehandSessionEnded"; + sessionId: string; + reason: "manual" | "ttl_expired" | "cache_evicted" | "error"; +} + +// ===== REQUEST LIFECYCLE EVENTS ===== + +export interface StagehandRequestReceivedEvent extends StagehandServerEvent { + type: "StagehandRequestReceived"; + sessionId: string; + requestId: string; + method: string; + path: string; + headers: { + "x-stream-response"?: boolean; + "x-bb-api-key"?: string; + "x-model-api-key"?: string; + "x-sdk-version"?: string; + "x-language"?: string; + "x-sent-at"?: string; + }; + bodySize: number; +} + +export interface StagehandRequestValidatedEvent extends StagehandServerEvent { + type: "StagehandRequestValidated"; + sessionId: string; + requestId: string; + schemaVersion: "v3"; + parsedData: unknown; +} + +export interface StagehandRequestCompletedEvent extends StagehandServerEvent { + type: "StagehandRequestCompleted"; + sessionId: string; + requestId: string; + statusCode: number; + responseSize?: number; + durationMs: number; +} + +// ===== ACTION LIFECYCLE EVENTS ===== + +export interface StagehandActionStartedEvent extends StagehandServerEvent { + type: "StagehandActionStarted"; + sessionId: string; + requestId: string; + actionId?: string; // Will be set by cloud listeners + actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; + input: string | object; + options: object; + url: string; + frameId?: string; +} + +export interface StagehandActionProgressEvent extends StagehandServerEvent { + type: "StagehandActionProgress"; + sessionId: string; + requestId: string; + actionId?: string; + actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; + message: LogLine; +} + +export interface StagehandActionCompletedEvent extends StagehandServerEvent { + type: "StagehandActionCompleted"; + sessionId: string; + requestId: string; + actionId?: string; + actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; + result: unknown; + metrics?: { + promptTokens: number; + completionTokens: number; + inferenceTimeMs: number; + }; + durationMs: number; +} + +export interface StagehandActionErroredEvent extends StagehandServerEvent { + type: "StagehandActionErrored"; + sessionId: string; + requestId: string; + actionId?: string; + actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; + error: { + message: string; + stack?: string; + code?: string; + }; + durationMs: number; +} + +// ===== STREAMING EVENTS ===== + +export interface StagehandStreamStartedEvent extends StagehandServerEvent { + type: "StagehandStreamStarted"; + sessionId: string; + requestId: string; +} + +export interface StagehandStreamMessageSentEvent extends StagehandServerEvent { + type: "StagehandStreamMessageSent"; + sessionId: string; + requestId: string; + messageType: "system" | "log"; + data: unknown; +} + +export interface StagehandStreamEndedEvent extends StagehandServerEvent { + type: "StagehandStreamEnded"; + sessionId: string; + requestId: string; +} + +// ===== CACHE EVENTS ===== + +export interface StagehandCacheHitEvent extends StagehandServerEvent { + type: "StagehandCacheHit"; + sessionId: string; + cacheKey: string; +} + +export interface StagehandCacheMissedEvent extends StagehandServerEvent { + type: "StagehandCacheMissed"; + sessionId: string; + cacheKey: string; +} + +export interface StagehandCacheEvictedEvent extends StagehandServerEvent { + type: "StagehandCacheEvicted"; + sessionId: string; + cacheKey: string; + reason: "lru" | "ttl" | "manual"; +} + +// ===== LLM REQUEST/RESPONSE EVENTS ===== + +export interface StagehandLLMRequestEvent extends StagehandServerEvent { + type: "StagehandLLMRequest"; + requestId: string; + + // Model config + modelName: string; + temperature?: number; + maxTokens?: number; + + // Request data + messages: Array<{ + role: "user" | "assistant" | "system"; + content: string | Array<{ type: string; text?: string; image?: string }>; + }>; + tools?: Array<{ + name: string; + description: string; + parameters: Record; + }>; + schema?: Record; // JSON schema for structured output + + // Context + requestType?: "act" | "extract" | "observe" | "agent" | "cua"; +} + +export interface StagehandLLMResponseEvent extends StagehandServerEvent { + type: "StagehandLLMResponse"; + requestId: string; // Must match StagehandLLMRequestEvent.requestId + + // Response data + content: string; + toolCalls?: Array<{ + id: string; + name: string; + arguments: Record; + }>; + finishReason: string; + + // Metrics + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; + + // Raw and parsed responses (for internal use) + rawResponse?: unknown; + parsedResponse?: unknown; + + // Error handling + error?: { + message: string; + code?: string; + }; +} + +export interface StagehandLLMErrorEvent extends StagehandServerEvent { + type: "StagehandLLMError"; + requestId: string; + error: { + message: string; + code?: string; + stack?: string; + }; +} + +// Union type for all events +export type StagehandServerEventType = + | StagehandServerStartedEvent + | StagehandServerReadyEvent + | StagehandServerShutdownEvent + | StagehandSessionCreatedEvent + | StagehandSessionResumedEvent + | StagehandSessionInitializedEvent + | StagehandSessionEndedEvent + | StagehandRequestReceivedEvent + | StagehandRequestValidatedEvent + | StagehandRequestCompletedEvent + | StagehandActionStartedEvent + | StagehandActionProgressEvent + | StagehandActionCompletedEvent + | StagehandActionErroredEvent + | StagehandStreamStartedEvent + | StagehandStreamMessageSentEvent + | StagehandStreamEndedEvent + | StagehandCacheHitEvent + | StagehandCacheMissedEvent + | StagehandCacheEvictedEvent + | StagehandLLMRequestEvent + | StagehandLLMResponseEvent + | StagehandLLMErrorEvent; + +// Type-safe event emitter interface +export interface StagehandServerEventMap { + StagehandServerStarted: StagehandServerStartedEvent; + StagehandServerReady: StagehandServerReadyEvent; + StagehandServerShutdown: StagehandServerShutdownEvent; + StagehandSessionCreated: StagehandSessionCreatedEvent; + StagehandSessionResumed: StagehandSessionResumedEvent; + StagehandSessionInitialized: StagehandSessionInitializedEvent; + StagehandSessionEnded: StagehandSessionEndedEvent; + StagehandRequestReceived: StagehandRequestReceivedEvent; + StagehandRequestValidated: StagehandRequestValidatedEvent; + StagehandRequestCompleted: StagehandRequestCompletedEvent; + StagehandActionStarted: StagehandActionStartedEvent; + StagehandActionProgress: StagehandActionProgressEvent; + StagehandActionCompleted: StagehandActionCompletedEvent; + StagehandActionErrored: StagehandActionErroredEvent; + StagehandStreamStarted: StagehandStreamStartedEvent; + StagehandStreamMessageSent: StagehandStreamMessageSentEvent; + StagehandStreamEnded: StagehandStreamEndedEvent; + StagehandCacheHit: StagehandCacheHitEvent; + StagehandCacheMissed: StagehandCacheMissedEvent; + StagehandCacheEvicted: StagehandCacheEvictedEvent; + StagehandLLMRequest: StagehandLLMRequestEvent; + StagehandLLMResponse: StagehandLLMResponseEvent; + StagehandLLMError: StagehandLLMErrorEvent; +} diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 7ec174270..d196daee4 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -11,6 +11,7 @@ import type { ObserveOptions, Action, AgentResult, + ModelConfiguration, } from "../types/public"; import type { StagehandZodSchema } from "../zodCompat"; import { SessionManager } from "./sessions"; @@ -285,10 +286,10 @@ export class StagehandServer { // Build options const safeOptions: ActOptions = { model: data.options?.model - ? { + ? ({ ...data.options.model, modelName: data.options.model.model ?? "gpt-4o", - } + } as ModelConfiguration) : undefined, variables: data.options?.variables, timeout: data.options?.timeout, @@ -402,10 +403,10 @@ export class StagehandServer { const safeOptions: ExtractOptions = { model: data.options?.model - ? { + ? ({ ...data.options.model, modelName: data.options.model.model ?? "gpt-4o", - } + } as ModelConfiguration) : undefined, timeout: data.options?.timeout, selector: data.options?.selector, @@ -526,10 +527,10 @@ export class StagehandServer { const safeOptions: ObserveOptions = { model: data.options?.model && typeof data.options.model.model === "string" - ? { + ? ({ ...data.options.model, modelName: data.options.model.model, - } + } as ModelConfiguration) : undefined, timeout: data.options?.timeout, selector: data.options?.selector, diff --git a/packages/core/lib/v3/v3.ts b/packages/core/lib/v3/v3.ts index 03f2f38cc..17c181801 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -602,6 +602,7 @@ export class V3 { this.modelName, this.modelClientOptions, (model) => this.resolveLlmClient(model), + this.eventBus, this.opts.systemPrompt ?? "", this.logInferenceToFile, this.opts.selfHeal ?? true, @@ -628,6 +629,7 @@ export class V3 { this.modelName, this.modelClientOptions, (model) => this.resolveLlmClient(model), + this.eventBus, this.opts.systemPrompt ?? "", this.logInferenceToFile, this.experimental, @@ -653,6 +655,7 @@ export class V3 { this.modelName, this.modelClientOptions, (model) => this.resolveLlmClient(model), + this.eventBus, this.opts.systemPrompt ?? "", this.logInferenceToFile, this.experimental, diff --git a/packages/core/package.json b/packages/core/package.json index 480f6c9b7..988186de5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,6 +36,8 @@ "files": [ "dist/index.js", "dist/index.d.ts", + "dist/server.js", + "dist/server.d.ts", "dist/lib", "dist/types", "dist/stagehand.config.d.ts" From 3c61ca325d28658bf7adcb08c794123ad7471eee Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 2 Dec 2025 15:19:19 -0800 Subject: [PATCH 07/30] fix json schema to zod conversion --- .../v3/server/CLOUD_INTEGRATION_EXAMPLE.md | 415 ------------------ packages/core/lib/v3/server/index.ts | 14 +- 2 files changed, 10 insertions(+), 419 deletions(-) delete mode 100644 packages/core/lib/v3/server/CLOUD_INTEGRATION_EXAMPLE.md diff --git a/packages/core/lib/v3/server/CLOUD_INTEGRATION_EXAMPLE.md b/packages/core/lib/v3/server/CLOUD_INTEGRATION_EXAMPLE.md deleted file mode 100644 index 6935ba88b..000000000 --- a/packages/core/lib/v3/server/CLOUD_INTEGRATION_EXAMPLE.md +++ /dev/null @@ -1,415 +0,0 @@ -# Cloud Server Integration with Event-Based Architecture - -This document shows how the cloud stagehand-api server can use the library's StagehandServer with event listeners to add cloud-only logic (database, LaunchDarkly, Sentry, etc.). - -## Architecture Overview - -The library `StagehandServer` emits 21 different event types at key lifecycle points. The cloud server can instantiate this server and add event listeners to hook into these events for cloud-specific operations. - -## Event Types Available - -### Server Lifecycle -- `StagehandServerStarted` - Server begins listening -- `StagehandServerReady` - Server fully initialized -- `StagehandServerShutdown` - Server is shutting down - -### Session Lifecycle -- `StagehandSessionCreated` - New session created -- `StagehandSessionResumed` - Session retrieved (cache hit/miss) -- `StagehandSessionInitialized` - Stagehand instance initialized -- `StagehandSessionEnded` - Session cleanup complete - -### Request Lifecycle -- `StagehandRequestReceived` - HTTP request received -- `StagehandRequestValidated` - Request body validated -- `StagehandRequestCompleted` - Response sent to client - -### Action Lifecycle -- `StagehandActionStarted` - act/extract/observe/agentExecute/navigate begins -- `StagehandActionProgress` - Log message during execution -- `StagehandActionCompleted` - Action finished successfully -- `StagehandActionErrored` - Action failed with error - -### Streaming Events -- `StagehandStreamStarted` - SSE connection opened -- `StagehandStreamMessageSent` - SSE message sent -- `StagehandStreamEnded` - SSE connection closed - -### Cache Events -- `StagehandCacheHit` - Session found in cache -- `StagehandCacheMissed` - Session not in cache -- `StagehandCacheEvicted` - Session removed from cache - -## Example: Cloud Server Implementation - -```typescript -// core/apps/stagehand-api/src/cloud-server.ts - -import { StagehandServer } from '@browserbasehq/stagehand/server'; -import type { - StagehandActionStartedEvent, - StagehandActionCompletedEvent, - StagehandActionErroredEvent, - StagehandSessionCreatedEvent, -} from '@browserbasehq/stagehand/server'; -import { db } from './lib/db'; -import { - createAction, - updateActionResult, - createInference, - updateActionStartAndEndTime, - createSession as createDbSession, -} from './lib/db/actions'; -import * as Sentry from '@sentry/node'; -import { Browserbase } from '@browserbase/sdk'; -import { LaunchDarklyClient } from './lib/launchdarkly'; - -export class CloudStagehandServer { - private stagehandServer: StagehandServer; - private launchdarkly: LaunchDarklyClient; - private browserbase: Browserbase; - - constructor() { - // Instantiate the library server - this.stagehandServer = new StagehandServer({ - port: 3000, - host: '0.0.0.0', - sessionTTL: 300000, // 5 minutes - }); - - this.launchdarkly = new LaunchDarklyClient(); - this.browserbase = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY! }); - - // Register all cloud-specific event listeners - this.registerEventListeners(); - } - - private registerEventListeners() { - // ===== SERVER LIFECYCLE ===== - - this.stagehandServer.on('StagehandServerReady', async (event) => { - console.log(`Cloud Stagehand API ready on port ${event.port}`); - // Initialize LaunchDarkly, connect to DB, etc. - await this.launchdarkly.initialize(); - }); - - this.stagehandServer.on('StagehandServerShutdown', async (event) => { - console.log('Shutting down cloud services...'); - await this.launchdarkly.close(); - // Cleanup cloud resources - }); - - // ===== SESSION LIFECYCLE ===== - - this.stagehandServer.on('StagehandSessionCreated', async (event: StagehandSessionCreatedEvent) => { - console.log(`Session created: ${event.sessionId}`); - - // Check LaunchDarkly rollout - const isAvailable = await this.launchdarkly.getFlagValue( - 'stagehand-api-ga-rollout', - { key: event.sessionId }, - false - ); - - if (!isAvailable) { - throw new Error('API not available for this user'); - } - - // Create Browserbase session - const bbSession = await this.browserbase.sessions.create({ - projectId: event.config.browserbase?.projectId, - keepAlive: true, - userMetadata: { stagehand: 'true' }, - }); - - // Store in database - await createDbSession({ - id: bbSession.id, - browserbaseApiKey: process.env.BROWSERBASE_API_KEY!, - browserbaseProjectId: event.config.browserbase?.projectId!, - modelName: event.config.model?.modelName || 'gpt-4o', - domSettleTimeoutMs: event.config.domSettleTimeoutMs, - verbose: event.config.verbose, - systemPrompt: event.config.systemPrompt, - selfHeal: event.config.selfHeal, - }); - }); - - // ===== REQUEST LIFECYCLE ===== - - this.stagehandServer.on('StagehandRequestReceived', async (event) => { - console.log(`Request received: ${event.method} ${event.path}`); - - // Authenticate (if needed) - const apiKey = event.headers['x-bb-api-key']; - if (apiKey && !await this.validateApiKey(apiKey)) { - throw new Error('Invalid API key'); - } - - // Check if session exists in DB - if (event.sessionId) { - const session = await db.sessions.findById(event.sessionId); - if (!session) { - throw new Error('Session not found in database'); - } - } - }); - - // ===== ACTION LIFECYCLE ===== - - this.stagehandServer.on('StagehandActionStarted', async (event: StagehandActionStartedEvent) => { - console.log(`Action started: ${event.actionType} for session ${event.sessionId}`); - - // Create action record in database - const action = await createAction({ - sessionId: event.sessionId, - method: event.actionType, - xpath: '', - options: event.options, - url: event.url, - }); - - // IMPORTANT: Set actionId on event so other listeners can use it - event.actionId = action.id; - }); - - this.stagehandServer.on('StagehandActionCompleted', async (event: StagehandActionCompletedEvent) => { - console.log(`Action completed: ${event.actionType} (${event.durationMs}ms)`); - - if (!event.actionId) { - console.warn('No actionId found on ActionCompleted event'); - return; - } - - // Update action result in database - await updateActionResult(event.actionId, event.result); - - // Log token usage metrics - if (event.metrics) { - await createInference(event.actionId, { - inputTokens: event.metrics.promptTokens, - outputTokens: event.metrics.completionTokens, - timeMs: event.metrics.inferenceTimeMs, - }); - } - - // Update timing - const sentAt = new Date(Date.now() - event.durationMs); - await updateActionStartAndEndTime(event.actionId, sentAt, new Date()); - }); - - this.stagehandServer.on('StagehandActionErrored', async (event: StagehandActionErroredEvent) => { - console.error(`Action failed: ${event.actionType} - ${event.error.message}`); - - // Send error to Sentry - Sentry.captureException(new Error(event.error.message), { - tags: { - sessionId: event.sessionId, - actionType: event.actionType, - actionId: event.actionId, - }, - extra: { - stack: event.error.stack, - durationMs: event.durationMs, - }, - }); - - // Update database with error - if (event.actionId) { - await updateActionResult(event.actionId, { - error: event.error.message, - success: false, - }); - } - }); - - // ===== PROGRESS LOGGING ===== - - this.stagehandServer.on('StagehandActionProgress', async (event) => { - // Could stream to external logging service (DataDog, CloudWatch, etc.) - // console.log(`[${event.sessionId}] ${event.message.message}`); - }); - } - - private async validateApiKey(apiKey: string): Promise { - // Implement API key validation - return apiKey.startsWith('bb_'); - } - - async start() { - await this.stagehandServer.listen(); - } - - async stop() { - await this.stagehandServer.close(); - } - - // Expose the underlying server for direct access if needed - getServer(): StagehandServer { - return this.stagehandServer; - } -} - -// Usage -const cloudServer = new CloudStagehandServer(); -await cloudServer.start(); -``` - -## Key Patterns - -### 1. Mutating Events for Communication - -Cloud listeners can set properties on events to communicate data back to the library: - -```typescript -stagehandServer.on('StagehandActionStarted', async (event) => { - const action = await createAction({...}); - event.actionId = action.id; // Set actionId for downstream listeners -}); -``` - -### 2. Async Listeners - -All event listeners are awaited, so you can perform async operations: - -```typescript -stagehandServer.on('StagehandSessionCreated', async (event) => { - await db.sessions.create({...}); // Waits for DB write -}); -``` - -### 3. Error Handling in Listeners - -Errors in listeners will propagate and fail the request: - -```typescript -stagehandServer.on('StagehandRequestReceived', async (event) => { - const session = await db.sessions.findById(event.sessionId); - if (!session) { - throw new Error('Session not found'); // Request fails with 500 - } -}); -``` - -### 4. Conditional Logic Based on Events - -```typescript -stagehandServer.on('StagehandSessionResumed', async (event) => { - if (event.fromCache) { - console.log('Cache hit!'); - // No need to recreate Stagehand instance - } else { - console.log('Cache miss - initializing new instance'); - // Fetch from DB, connect to Browserbase, etc. - } -}); -``` - -## Benefits of Event-Based Architecture - -1. **Zero Code Duplication**: Library contains all core logic, cloud adds hooks -2. **Clean Separation**: Cloud concerns (DB, monitoring, etc.) isolated in listeners -3. **Easy Testing**: Test library and cloud independently -4. **Flexible**: Add new cloud features by adding new listeners -5. **Version Independence**: Library and cloud can evolve separately -6. **Type Safety**: All events are fully typed with TypeScript - -## Migration Path for Existing Cloud Server - -1. **Phase 1**: Instantiate `StagehandServer` in cloud server -2. **Phase 2**: Add event listeners for all DB operations -3. **Phase 3**: Remove duplicated route handlers from cloud server -4. **Phase 4**: Delete ~2,100 lines of duplicated code -5. **Result**: Cloud server becomes pure event orchestrator (~500 lines) - -## Complete Event Listener Template - -```typescript -private registerAllEvents() { - // Server - this.stagehandServer.on('StagehandServerStarted', async (event) => {}); - this.stagehandServer.on('StagehandServerReady', async (event) => {}); - this.stagehandServer.on('StagehandServerShutdown', async (event) => {}); - - // Session - this.stagehandServer.on('StagehandSessionCreated', async (event) => {}); - this.stagehandServer.on('StagehandSessionResumed', async (event) => {}); - this.stagehandServer.on('StagehandSessionInitialized', async (event) => {}); - this.stagehandServer.on('StagehandSessionEnded', async (event) => {}); - - // Request - this.stagehandServer.on('StagehandRequestReceived', async (event) => {}); - this.stagehandServer.on('StagehandRequestValidated', async (event) => {}); - this.stagehandServer.on('StagehandRequestCompleted', async (event) => {}); - - // Action - this.stagehandServer.on('StagehandActionStarted', async (event) => {}); - this.stagehandServer.on('StagehandActionProgress', async (event) => {}); - this.stagehandServer.on('StagehandActionCompleted', async (event) => {}); - this.stagehandServer.on('StagehandActionErrored', async (event) => {}); - - // Stream - this.stagehandServer.on('StagehandStreamStarted', async (event) => {}); - this.stagehandServer.on('StagehandStreamMessageSent', async (event) => {}); - this.stagehandServer.on('StagehandStreamEnded', async (event) => {}); - - // Cache (optional - mostly for cloud with multi-session support) - this.stagehandServer.on('StagehandCacheHit', async (event) => {}); - this.stagehandServer.on('StagehandCacheMissed', async (event) => {}); - this.stagehandServer.on('StagehandCacheEvicted', async (event) => {}); -} -``` - -## Testing Event Listeners - -```typescript -import { StagehandServer } from '@browserbasehq/stagehand/server'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; - -describe('Cloud Server Event Listeners', () => { - let server: StagehandServer; - let events: any[] = []; - - beforeEach(async () => { - server = new StagehandServer({ port: 3001 }); - - // Capture all events - server.on('StagehandActionStarted', (event) => { - events.push(event); - }); - - await server.listen(); - }); - - afterEach(async () => { - await server.close(); - events = []; - }); - - it('should emit ActionStarted event when act is called', async () => { - // Create session and call act endpoint - // ... - - expect(events).toHaveLength(1); - expect(events[0].type).toBe('StagehandActionStarted'); - expect(events[0].actionType).toBe('act'); - }); -}); -``` - -## Next Steps - -1. Import `StagehandServer` and event types in cloud server -2. Create `CloudStagehandServer` wrapper class -3. Register event listeners for all DB, LaunchDarkly, Sentry operations -4. Test that cloud server works with event-based architecture -5. Remove old route handler files from cloud server -6. Deploy and verify in production - -## Result - -- **Library Server**: 946 lines (pure logic, zero cloud dependencies) -- **Cloud Server**: ~500 lines (event orchestration + cloud integrations) -- **Code Reduction**: 81% (from 2,661 to 500 lines in cloud server) -- **Maintainability**: Single source of truth for all route logic -- **Flexibility**: Easy to add new cloud features via new listeners diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index d196daee4..9425b4026 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -14,6 +14,7 @@ import type { ModelConfiguration, } from "../types/public"; import type { StagehandZodSchema } from "../zodCompat"; +import { jsonSchemaToZod, type JsonSchema } from "../../utils"; import { SessionManager } from "./sessions"; import { createStreamingResponse } from "./stream"; import { @@ -417,10 +418,15 @@ export class StagehandServer { 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); + // Convert JSON schema (sent by StagehandAPIClient) back to a Zod schema + const zodSchema = jsonSchemaToZod( + data.schema as unknown as JsonSchema, + ) as StagehandZodSchema; + result = await stagehand.extract( + data.instruction, + zodSchema, + safeOptions, + ); } else { result = await stagehand.extract(data.instruction, safeOptions); } From 854fe578ea9a18907527865f9dc2b1f088410b97 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 2 Dec 2025 16:04:23 -0800 Subject: [PATCH 08/30] eliminate branching in remote vs p2p connection process --- packages/core/examples/p2p-client-example.ts | 13 +- packages/core/examples/p2p-server-example.ts | 7 +- packages/core/lib/v3/api.ts | 28 +-- packages/core/lib/v3/server/index.ts | 57 +++-- .../tests/connect-to-existing-browser.spec.ts | 2 - .../core/lib/v3/tests/v3.dynamic.config.ts | 1 - packages/core/lib/v3/types/public/options.ts | 1 - packages/core/lib/v3/v3.ts | 216 ++++-------------- .../integration/p2p-server-client.test.ts | 15 +- 9 files changed, 128 insertions(+), 212 deletions(-) diff --git a/packages/core/examples/p2p-client-example.ts b/packages/core/examples/p2p-client-example.ts index 43015aada..0f4fc4b5a 100644 --- a/packages/core/examples/p2p-client-example.ts +++ b/packages/core/examples/p2p-client-example.ts @@ -22,14 +22,19 @@ async function main() { console.log("=".repeat(60)); console.log(`Connecting to server at ${SERVER_URL}...`); - // Create a Stagehand instance + // When STAGEHAND_API_URL is set to the P2P server URL + // (e.g. "http://localhost:3000/v1"), Stagehand will use the HTTP API + // instead of launching a local browser. + if (!process.env.STAGEHAND_API_URL) { + process.env.STAGEHAND_API_URL = `${SERVER_URL}/v1`; + } + const stagehand = new Stagehand({ - env: "LOCAL", // Required but won't be used since we're connecting to remote + env: "BROWSERBASE", verbose: 1, }); - // Connect to the remote server and create a session - await stagehand.connectToRemoteServer(SERVER_URL); + await stagehand.init(); console.log("✓ Connected to remote server\n"); // Navigate to a test page first diff --git a/packages/core/examples/p2p-server-example.ts b/packages/core/examples/p2p-server-example.ts index 69ebb80b7..5a29ee7b1 100644 --- a/packages/core/examples/p2p-server-example.ts +++ b/packages/core/examples/p2p-server-example.ts @@ -70,7 +70,12 @@ async function main() { 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(" // In your client process:"); + console.log(` process.env.STAGEHAND_API_URL = '${server.getUrl()}/v1';`); + console.log( + " const stagehand = new Stagehand({ env: 'LOCAL', verbose: 1 });", + ); + console.log(" await stagehand.init();"); console.log("\nPress Ctrl+C to stop the server"); console.log("=".repeat(60)); diff --git a/packages/core/lib/v3/api.ts b/packages/core/lib/v3/api.ts index 0b692bfdb..1b0f72bdb 100644 --- a/packages/core/lib/v3/api.ts +++ b/packages/core/lib/v3/api.ts @@ -70,17 +70,23 @@ export class StagehandAPIClient { }: StagehandAPIConstructorParams) { this.apiKey = apiKey; this.projectId = projectId; - this.baseUrl = + const resolvedBaseUrl = baseUrl || process.env.STAGEHAND_API_URL || "https://api.stagehand.browserbase.com/v1"; + this.baseUrl = resolvedBaseUrl; this.logger = logger; - // Validate: if using cloud API, apiKey and projectId are required - if (!baseUrl && (!apiKey || !projectId)) { + // Validate: if using the default cloud API (no explicit override), + // apiKey and projectId are required. When STAGEHAND_API_URL or an + // explicit baseUrl is provided, we allow missing keys so the same + // client can talk to both cloud and P2P servers. + const usingDefaultCloud = + !baseUrl && !process.env.STAGEHAND_API_URL; + if (usingDefaultCloud && (!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.", + "Set STAGEHAND_API_URL or provide a baseUrl to connect to a local Stagehand server instead.", ); } @@ -504,18 +510,14 @@ export class StagehandAPIClient { }; // 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; - } + // Always send auth-related headers; servers that don't require them + // (e.g. P2P or self-hosted) will simply ignore empty values. + defaultHeaders["x-bb-api-key"] = this.apiKey ?? ""; + 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; - } + defaultHeaders["x-model-api-key"] = this.modelApiKey ?? ""; if (options.method === "POST" && options.body) { defaultHeaders["Content-Type"] = "application/json"; diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 9425b4026..e8b1a176d 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -24,6 +24,7 @@ import { agentExecuteSchemaV3, navigateSchemaV3, } from "./schemas"; +import type { StartSessionParams } from "../types/private/api"; import type { StagehandServerEventMap, StagehandRequestReceivedEvent, @@ -162,15 +163,13 @@ export class StagehandServer { 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 + sessionId: "", + // No session yet method: "POST", path: "/v1/sessions/start", headers: { @@ -184,8 +183,34 @@ export class StagehandServer { bodySize: JSON.stringify(request.body).length, }); + const body = request.body as unknown; + + let v3Config: V3Options; + + if (body && typeof body === "object" && "env" in (body as any)) { + // Backwards-compatible path: accept full V3Options directly + v3Config = body as V3Options; + } else { + // Cloud-compatible path: accept StartSessionParams and derive V3Options + const params = body as StartSessionParams; + + const modelConfig: ModelConfiguration = { + modelName: params.modelName as any, + apiKey: params.modelApiKey, + }; + + v3Config = { + env: "LOCAL", + model: modelConfig, + systemPrompt: params.systemPrompt, + domSettleTimeout: params.domSettleTimeoutMs, + verbose: params.verbose as 0 | 1 | 2, + selfHeal: params.selfHeal, + }; + } + // Create session (will emit StagehandSessionCreated) - const sessionId = this.sessionManager.createSession(config); + const sessionId = this.sessionManager.createSession(v3Config); // Emit request completed event await this.emitAsync("StagehandRequestCompleted", { @@ -197,9 +222,13 @@ export class StagehandServer { durationMs: Date.now() - startTime, }); + // Match cloud API shape: { success: true, data: { sessionId, available } } reply.status(200).send({ - sessionId, - available: true, + success: true, + data: { + sessionId, + available: true, + }, }); } catch (error) { await this.emitAsync("StagehandRequestCompleted", { @@ -212,7 +241,9 @@ export class StagehandServer { }); reply.status(500).send({ - error: error instanceof Error ? error.message : "Failed to create session", + success: false, + message: + error instanceof Error ? error.message : "Failed to create session", }); } } @@ -272,7 +303,7 @@ export class StagehandServer { reply, eventBus: this.eventBus, handler: async (ctx, data) => { - const { stagehand } = ctx; + const stagehand = ctx.stagehand as any; const { frameId } = data; // Get the page @@ -391,7 +422,7 @@ export class StagehandServer { reply, eventBus: this.eventBus, handler: async (ctx, data) => { - const { stagehand } = ctx; + const stagehand = ctx.stagehand as any; const { frameId } = data; const page = frameId @@ -519,7 +550,7 @@ export class StagehandServer { reply, eventBus: this.eventBus, handler: async (ctx, data) => { - const { stagehand } = ctx; + const stagehand = ctx.stagehand as any; const { frameId } = data; const page = frameId @@ -636,7 +667,7 @@ export class StagehandServer { reply, eventBus: this.eventBus, handler: async (ctx, data) => { - const { stagehand } = ctx; + const stagehand = ctx.stagehand as any; const { agentConfig, executeOptions, frameId } = data; const page = frameId @@ -739,7 +770,7 @@ export class StagehandServer { reply, eventBus: this.eventBus, handler: async (ctx, data: any) => { - const { stagehand } = ctx; + const stagehand = ctx.stagehand as any; const { url, options, frameId } = data; if (!url) { diff --git a/packages/core/lib/v3/tests/connect-to-existing-browser.spec.ts b/packages/core/lib/v3/tests/connect-to-existing-browser.spec.ts index 6b3f83100..3063e49d3 100644 --- a/packages/core/lib/v3/tests/connect-to-existing-browser.spec.ts +++ b/packages/core/lib/v3/tests/connect-to-existing-browser.spec.ts @@ -17,7 +17,6 @@ test.describe("connect to existing Browserbase session", () => { const initialStagehand = new V3({ ...v3BBTestConfig, - disableAPI: true, }); await initialStagehand.init(); @@ -44,7 +43,6 @@ test.describe("connect to existing Browserbase session", () => { env: "LOCAL", verbose: 0, disablePino: true, - disableAPI: true, logger: v3BBTestConfig.logger, localBrowserLaunchOptions: { cdpUrl: sessionUrl, diff --git a/packages/core/lib/v3/tests/v3.dynamic.config.ts b/packages/core/lib/v3/tests/v3.dynamic.config.ts index cdb87678b..c886cab74 100644 --- a/packages/core/lib/v3/tests/v3.dynamic.config.ts +++ b/packages/core/lib/v3/tests/v3.dynamic.config.ts @@ -27,7 +27,6 @@ export const v3DynamicTestConfig: V3Options = env: "BROWSERBASE", apiKey: process.env.BROWSERBASE_API_KEY!, projectId: process.env.BROWSERBASE_PROJECT_ID!, - disableAPI: true, selfHeal: false, } : { diff --git a/packages/core/lib/v3/types/public/options.ts b/packages/core/lib/v3/types/public/options.ts index 3ccb4cfe4..219bf3480 100644 --- a/packages/core/lib/v3/types/public/options.ts +++ b/packages/core/lib/v3/types/public/options.ts @@ -86,7 +86,6 @@ export interface V3Options { /** Directory used to persist cached actions for act(). */ 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 17c181801..1b263bc46 100644 --- a/packages/core/lib/v3/v3.ts +++ b/packages/core/lib/v3/v3.ts @@ -161,7 +161,6 @@ export class V3 { }; public readonly experimental: boolean = false; public readonly logInferenceToFile: boolean = false; - public readonly disableAPI: boolean = false; private externalLogger?: (logLine: LogLine) => void; public verbose: 0 | 1 | 2 = 1; private stagehandLogger: StagehandLogger; @@ -250,7 +249,6 @@ export class V3 { this.logInferenceToFile = opts.logInferenceToFile ?? false; this.llmProvider = new LLMProvider(this.logger); this.domSettleTimeoutMs = opts.domSettleTimeout; - this.disableAPI = opts.disableAPI ?? false; const baseClientOptions: ClientOptions = clientOptions ? ({ ...clientOptions } as ClientOptions) @@ -258,7 +256,6 @@ export class V3 { if (opts.llmClient) { this.llmClient = opts.llmClient; this.modelClientOptions = baseClientOptions; - this.disableAPI = true; } else { // Ensure API key is set let apiKey = (baseClientOptions as { apiKey?: string }).apiKey; @@ -676,6 +673,7 @@ export class V3 { inferenceTimeMs, ), ); + if (this.opts.env === "LOCAL") { // chrome-launcher conditionally adds --headless when the environment variable // HEADLESS is set, without parsing its value. @@ -806,111 +804,63 @@ export class V3 { } if (this.opts.env === "BROWSERBASE") { - const { apiKey, projectId } = this.requireBrowserbaseCreds(); - if (!apiKey || !projectId) { - throw new MissingEnvironmentVariableError( - "BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID", - "Browserbase environment", - ); - } + // In BROWSERBASE mode, always use the HTTP API client. + // STAGEHAND_API_URL controls which remote server we talk to: + // - default: Stagehand cloud API + // - custom: P2P or self-hosted Stagehand server + const apiKey = + this.opts.apiKey ?? process.env.BROWSERBASE_API_KEY ?? ""; + const projectId = + this.opts.projectId ?? process.env.BROWSERBASE_PROJECT_ID ?? ""; + this.logger({ category: "init", - message: "Starting browserbase session", + message: "Connecting to remote Stagehand API (BROWSERBASE env)", level: 1, }); - if (!this.disableAPI && !this.experimental) { - this.apiClient = new StagehandAPIClient({ - apiKey, - projectId, - logger: this.logger, - }); - const createSessionPayload = { - projectId: - this.opts.browserbaseSessionCreateParams?.projectId ?? - projectId, - ...this.opts.browserbaseSessionCreateParams, - browserSettings: { - ...(this.opts.browserbaseSessionCreateParams?.browserSettings ?? - {}), - viewport: this.opts.browserbaseSessionCreateParams - ?.browserSettings?.viewport ?? { width: 1288, height: 711 }, - }, - userMetadata: { - ...(this.opts.browserbaseSessionCreateParams?.userMetadata ?? - {}), - stagehand: "true", - }, - }; - const { sessionId, available } = await this.apiClient.init({ - modelName: this.modelName, - modelApiKey: this.modelClientOptions.apiKey, - domSettleTimeoutMs: this.domSettleTimeoutMs, - verbose: this.verbose, - systemPrompt: this.opts.systemPrompt, - selfHeal: this.opts.selfHeal, - browserbaseSessionCreateParams: createSessionPayload, - browserbaseSessionID: this.opts.browserbaseSessionID, - }); - if (!available) { - this.apiClient = null; - } - this.opts.browserbaseSessionID = sessionId; - } - const { ws, sessionId, bb } = await createBrowserbaseSession( + + this.apiClient = new StagehandAPIClient({ apiKey, projectId, - this.opts.browserbaseSessionCreateParams, - this.opts.browserbaseSessionID, - ); - this.ctx = await V3Context.create(ws, { - env: "BROWSERBASE", - apiClient: this.apiClient, + // baseUrl is resolved inside StagehandAPIClient from + // STAGEHAND_API_URL or defaults to the cloud API. + logger: this.logger, }); - this.ctx.conn.onTransportClosed(this._onCdpClosed); - this.state = { kind: "BROWSERBASE", sessionId, ws, bb }; - this.browserbaseSessionId = sessionId; - await this._ensureBrowserbaseDownloadsEnabled(); + const createSessionPayload = { + projectId: + this.opts.browserbaseSessionCreateParams?.projectId ?? + projectId, + ...this.opts.browserbaseSessionCreateParams, + browserSettings: { + ...(this.opts.browserbaseSessionCreateParams?.browserSettings ?? + {}), + viewport: this.opts.browserbaseSessionCreateParams + ?.browserSettings?.viewport ?? { width: 1288, height: 711 }, + }, + userMetadata: { + ...(this.opts.browserbaseSessionCreateParams?.userMetadata ?? + {}), + stagehand: "true", + }, + }; - const resumed = !!this.opts.browserbaseSessionID; - let debugUrl: string | undefined; - try { - const dbg = (await bb.sessions.debug(sessionId)) as unknown as { - debuggerUrl?: string; - }; - debugUrl = dbg?.debuggerUrl; - } catch { - // Ignore debug fetch failures; continue with sessionUrl only - } - const sessionUrl = `https://www.browserbase.com/sessions/${sessionId}`; - this.browserbaseSessionUrl = sessionUrl; - this.browserbaseDebugUrl = debugUrl; + await this.apiClient.init({ + modelName: this.modelName, + modelApiKey: this.modelClientOptions.apiKey!, + domSettleTimeoutMs: this.domSettleTimeoutMs ?? 10_000, + verbose: this.verbose, + systemPrompt: this.opts.systemPrompt, + selfHeal: this.opts.selfHeal, + browserbaseSessionCreateParams: createSessionPayload, + browserbaseSessionID: this.opts.browserbaseSessionID, + }); - try { - this.logger({ - category: "init", - message: resumed - ? this.apiClient - ? "Browserbase session started" - : "Browserbase session resumed" - : "Browserbase session started", - level: 1, - auxiliary: { - sessionUrl: { value: sessionUrl, type: "string" }, - ...(debugUrl && { - debugUrl: { value: debugUrl, type: "string" }, - }), - sessionId: { value: sessionId, type: "string" }, - }, - }); - } catch { - // best-effort logging — ignore failures - } + // In pure API mode we don't create a local CDP context here; + // all operations will be proxied via this.apiClient. + this.resetBrowserbaseSessionMetadata(); return; } - - const neverEnv: never = this.opts.env; - throw new StagehandInitError(`Unsupported env: ${neverEnv}`); }); } catch (error) { // Cleanup instanceLoggers map on init failure to prevent memory leak @@ -1894,80 +1844,6 @@ export class V3 { 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/tests/integration/p2p-server-client.test.ts b/packages/core/tests/integration/p2p-server-client.test.ts index 458626d8a..a6b285ebc 100644 --- a/packages/core/tests/integration/p2p-server-client.test.ts +++ b/packages/core/tests/integration/p2p-server-client.test.ts @@ -39,17 +39,18 @@ describe("P2P Server/Client Integration", () => { // Give the server a moment to fully start await new Promise((resolve) => setTimeout(resolve, 500)); - // Create the client-side Stagehand instance + // Point the client at the P2P server via STAGEHAND_API_URL so that it + // uses the HTTP API instead of launching a local browser. + process.env.STAGEHAND_API_URL = `${SERVER_URL}/v1`; + + // Create the client-side Stagehand instance configured to talk to the remote server clientStagehand = new Stagehand({ - env: "LOCAL", + env: "BROWSERBASE", 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 + // Initialize the client, which connects to the remote server + await clientStagehand.init(); }, 30000); // 30 second timeout for setup afterAll(async () => { From 920ec700099da65a50bc7467b2917243f2abd3f7 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 2 Dec 2025 18:43:22 -0800 Subject: [PATCH 09/30] wip generic SessionStore interface --- packages/core/lib/v3/server/events.ts | 8 --- packages/core/lib/v3/server/index.ts | 68 +++++++++---------- packages/core/lib/v3/server/sessions.ts | 16 ++--- packages/core/lib/v3/types/private/api.ts | 12 +++- .../browserbase-session-accessors.test.ts | 3 - 5 files changed, 49 insertions(+), 58 deletions(-) diff --git a/packages/core/lib/v3/server/events.ts b/packages/core/lib/v3/server/events.ts index 69d908e10..f1f326f26 100644 --- a/packages/core/lib/v3/server/events.ts +++ b/packages/core/lib/v3/server/events.ts @@ -29,12 +29,6 @@ export interface StagehandServerShutdownEvent extends StagehandServerEvent { // ===== SESSION LIFECYCLE EVENTS ===== -export interface StagehandSessionCreatedEvent extends StagehandServerEvent { - type: "StagehandSessionCreated"; - sessionId: string; - config: V3Options; -} - export interface StagehandSessionResumedEvent extends StagehandServerEvent { type: "StagehandSessionResumed"; sessionId: string; @@ -256,7 +250,6 @@ export type StagehandServerEventType = | StagehandServerStartedEvent | StagehandServerReadyEvent | StagehandServerShutdownEvent - | StagehandSessionCreatedEvent | StagehandSessionResumedEvent | StagehandSessionInitializedEvent | StagehandSessionEndedEvent @@ -282,7 +275,6 @@ export interface StagehandServerEventMap { StagehandServerStarted: StagehandServerStartedEvent; StagehandServerReady: StagehandServerReadyEvent; StagehandServerShutdown: StagehandServerShutdownEvent; - StagehandSessionCreated: StagehandSessionCreatedEvent; StagehandSessionResumed: StagehandSessionResumedEvent; StagehandSessionInitialized: StagehandSessionInitializedEvent; StagehandSessionEnded: StagehandSessionEndedEvent; diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index e8b1a176d..79fa0dadd 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -155,7 +155,7 @@ export class StagehandServer { /** * Handle /sessions/start - Create new session */ - private async handleStartSession( + async handleStartSession( request: FastifyRequest, reply: FastifyReply, ): Promise { @@ -183,34 +183,32 @@ export class StagehandServer { bodySize: JSON.stringify(request.body).length, }); - const body = request.body as unknown; - - let v3Config: V3Options; - - if (body && typeof body === "object" && "env" in (body as any)) { - // Backwards-compatible path: accept full V3Options directly - v3Config = body as V3Options; - } else { - // Cloud-compatible path: accept StartSessionParams and derive V3Options - const params = body as StartSessionParams; - - const modelConfig: ModelConfiguration = { - modelName: params.modelName as any, - apiKey: params.modelApiKey, - }; - - v3Config = { - env: "LOCAL", - model: modelConfig, - systemPrompt: params.systemPrompt, - domSettleTimeout: params.domSettleTimeoutMs, - verbose: params.verbose as 0 | 1 | 2, - selfHeal: params.selfHeal, - }; - } - - // Create session (will emit StagehandSessionCreated) - const sessionId = this.sessionManager.createSession(v3Config); + const params = request.body as StartSessionParams; + + // Derive V3Options from StartSessionParams — this mirrors what the + // cloud API does, and makes the P2P server a drop-in replacement. + const modelConfig: ModelConfiguration = { + modelName: params.modelName as any, + apiKey: params.modelApiKey, + }; + + const v3Config: V3Options = { + env: "LOCAL", + model: modelConfig, + systemPrompt: params.systemPrompt, + domSettleTimeout: params.domSettleTimeoutMs, + verbose: params.verbose as 0 | 1 | 2, + selfHeal: params.selfHeal, + }; + + // If an external sessionId is provided (e.g. from a cloud session store), + // use it as the in-memory key so cloud and library share the same ID. + const externalSessionId = params.sessionId ?? params.browserbaseSessionID; + + const sessionId = this.sessionManager.createSession( + v3Config, + externalSessionId, + ); // Emit request completed event await this.emitAsync("StagehandRequestCompleted", { @@ -251,7 +249,7 @@ export class StagehandServer { /** * Handle /sessions/:id/act - Execute act command */ - private async handleAct( + async handleAct( request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply, ): Promise { @@ -372,7 +370,7 @@ export class StagehandServer { /** * Handle /sessions/:id/extract - Execute extract command */ - private async handleExtract( + async handleExtract( request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply, ): Promise { @@ -500,7 +498,7 @@ export class StagehandServer { /** * Handle /sessions/:id/observe - Execute observe command */ - private async handleObserve( + async handleObserve( request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply, ): Promise { @@ -617,7 +615,7 @@ export class StagehandServer { /** * Handle /sessions/:id/agentExecute - Execute agent command */ - private async handleAgentExecute( + async handleAgentExecute( request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply, ): Promise { @@ -722,7 +720,7 @@ export class StagehandServer { /** * Handle /sessions/:id/navigate - Navigate to URL */ - private async handleNavigate( + async handleNavigate( request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply, ): Promise { @@ -822,7 +820,7 @@ export class StagehandServer { /** * Handle /sessions/:id/end - End session and cleanup */ - private async handleEndSession( + async handleEndSession( request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply, ): Promise { diff --git a/packages/core/lib/v3/server/sessions.ts b/packages/core/lib/v3/server/sessions.ts index ef7fcd973..4b3c5f43c 100644 --- a/packages/core/lib/v3/server/sessions.ts +++ b/packages/core/lib/v3/server/sessions.ts @@ -27,8 +27,12 @@ export class SessionManager { /** * Create a new session with the given config */ - createSession(config: V3Options): string { - const sessionId = randomUUID(); + createSession(config: V3Options, sessionIdOverride?: string): string { + const sessionId = sessionIdOverride ?? randomUUID(); + + if (this.sessions.has(sessionId)) { + throw new Error(`Session already exists with id: ${sessionId}`); + } this.sessions.set(sessionId, { sessionId, @@ -38,14 +42,6 @@ export class SessionManager { 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; } diff --git a/packages/core/lib/v3/types/private/api.ts b/packages/core/lib/v3/types/private/api.ts index a96f54717..3b059cf82 100644 --- a/packages/core/lib/v3/types/private/api.ts +++ b/packages/core/lib/v3/types/private/api.ts @@ -5,6 +5,7 @@ import { ExtractOptions, LogLine, ObserveOptions, + V3Options, } from "../public"; import type { Protocol } from "devtools-protocol"; import type { StagehandZodSchema } from "../../zodCompat"; @@ -22,11 +23,18 @@ export interface ExecuteActionParams { params?: unknown; } -export interface StartSessionParams { +export interface StartSessionParams extends Partial { + /** + * Optional external session identifier. + * When provided, StagehandServer will use this as the in-memory session id + * instead of generating a new UUID. This allows cloud environments to align + * library sessions with their own persisted session IDs (e.g. Browserbase). + */ + sessionId?: string; modelName: string; modelApiKey: string; domSettleTimeoutMs: number; - verbose: number; + verbose: 0 | 1 | 2; systemPrompt?: string; browserbaseSessionCreateParams?: Omit< Browserbase.Sessions.SessionCreateParams, diff --git a/packages/core/tests/browserbase-session-accessors.test.ts b/packages/core/tests/browserbase-session-accessors.test.ts index 1621596df..10a9af354 100644 --- a/packages/core/tests/browserbase-session-accessors.test.ts +++ b/packages/core/tests/browserbase-session-accessors.test.ts @@ -65,7 +65,6 @@ describe("browserbase accessors", () => { it("exposes Browserbase session and debug URLs after init", async () => { const v3 = new V3({ env: "BROWSERBASE", - disableAPI: true, verbose: 0, }); @@ -82,7 +81,6 @@ describe("browserbase accessors", () => { it("clears stored URLs after close", async () => { const v3 = new V3({ env: "BROWSERBASE", - disableAPI: true, verbose: 0, }); @@ -98,7 +96,6 @@ describe("local accessors", () => { it("stay empty for LOCAL environments", async () => { const v3 = new V3({ env: "LOCAL", - disableAPI: true, verbose: 0, localBrowserLaunchOptions: { cdpUrl: "ws://local-existing-session", From 501fcd49083a70243dd671de93de2a2d1bb081fa Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 14:42:18 -0800 Subject: [PATCH 10/30] implement SessionStore interface for cloud to hook into --- packages/core/lib/v3/index.ts | 9 +- .../lib/v3/server/InMemorySessionStore.ts | 331 ++++++++++++++++++ packages/core/lib/v3/server/SessionStore.ts | 174 +++++++++ packages/core/lib/v3/server/index.ts | 254 ++++++++++---- packages/core/lib/v3/server/schemas.ts | 159 ++++++++- packages/core/lib/v3/server/sessions.ts | 207 ----------- packages/core/lib/v3/server/stream.ts | 53 ++- packages/core/lib/v3/types/private/api.ts | 7 + 8 files changed, 893 insertions(+), 301 deletions(-) create mode 100644 packages/core/lib/v3/server/InMemorySessionStore.ts create mode 100644 packages/core/lib/v3/server/SessionStore.ts delete mode 100644 packages/core/lib/v3/server/sessions.ts diff --git a/packages/core/lib/v3/index.ts b/packages/core/lib/v3/index.ts index e924587ab..168d0a9f9 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -10,8 +10,13 @@ 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 { InMemorySessionStore } from "./server/InMemorySessionStore"; +export type { + SessionStore, + CreateSessionParams, + RequestContext, + SessionCacheConfig, +} from "./server/SessionStore"; export { AnnotatedScreenshotText, LLMClient } from "./llm/LLMClient"; export { AgentProvider, modelToAgentProviderMap } from "./agent/AgentProvider"; diff --git a/packages/core/lib/v3/server/InMemorySessionStore.ts b/packages/core/lib/v3/server/InMemorySessionStore.ts new file mode 100644 index 000000000..2a12ac507 --- /dev/null +++ b/packages/core/lib/v3/server/InMemorySessionStore.ts @@ -0,0 +1,331 @@ +import { randomUUID } from "crypto"; +import type { V3Options, LogLine } from "../types/public"; +import type { V3 } from "../v3"; +import type { + SessionStore, + CreateSessionParams, + RequestContext, + SessionCacheConfig, + StartSessionResult, +} from "./SessionStore"; + +const DEFAULT_MAX_CAPACITY = 100; +const DEFAULT_TTL_MS = 300_000; // 5 minutes + +/** + * Internal node for LRU linked list + */ +interface LruNode { + sessionId: string; + params: CreateSessionParams; + stagehand: V3 | null; + loggerRef: { current?: (message: LogLine) => void }; + expiry: number; + prev: LruNode | null; + next: LruNode | null; +} + +/** + * In-memory implementation of SessionStore with full caching support. + * + * Features: + * - LRU eviction when at capacity + * - TTL-based expiration + * - Lazy V3 instance creation + * - Dynamic logger updates for streaming + * - Automatic cleanup of evicted sessions + * + * This is the default implementation used when no custom store is provided. + * For stateless pod architectures, use a database-backed implementation. + */ +export class InMemorySessionStore implements SessionStore { + private first: LruNode | null = null; + private last: LruNode | null = null; + private items: Map = new Map(); + private maxCapacity: number; + private ttlMs: number; + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor(config?: SessionCacheConfig) { + this.maxCapacity = config?.maxCapacity ?? DEFAULT_MAX_CAPACITY; + this.ttlMs = config?.ttlMs ?? DEFAULT_TTL_MS; + this.startCleanupInterval(); + } + + /** + * Start periodic cleanup of expired sessions + */ + private startCleanupInterval(): void { + // Run cleanup every minute + this.cleanupInterval = setInterval(() => { + this.cleanupExpired(); + }, 60_000); + } + + /** + * Cleanup expired sessions + */ + private async cleanupExpired(): Promise { + const now = Date.now(); + const expiredIds: string[] = []; + + for (const [sessionId, node] of this.items.entries()) { + if (this.ttlMs > 0 && node.expiry <= now) { + expiredIds.push(sessionId); + } + } + + for (const sessionId of expiredIds) { + await this.deleteSession(sessionId); + } + } + + /** + * Bump a node to the end of the LRU list (most recently used) + */ + private bumpNode(node: LruNode): void { + // Update expiry + node.expiry = this.ttlMs > 0 ? Date.now() + this.ttlMs : Infinity; + + if (this.last === node) { + return; // Already most recent + } + + const { prev, next } = node; + + // Unlink from current position + if (prev) prev.next = next; + if (next) next.prev = prev; + if (this.first === node) this.first = next; + + // Link to end + node.prev = this.last; + node.next = null; + if (this.last) this.last.next = node; + this.last = node; + + if (!this.first) this.first = node; + } + + /** + * Evict the least recently used session + */ + private async evictLru(): Promise { + const lruNode = this.first; + if (!lruNode) return; + + await this.deleteSession(lruNode.sessionId); + } + + async startSession(params: CreateSessionParams): Promise { + // Generate session ID or use provided browserbase session ID + const sessionId = params.browserbaseSessionID ?? randomUUID(); + + // Store the session + await this.createSession(sessionId, params); + + return { + sessionId, + available: true, + }; + } + + async endSession(sessionId: string): Promise { + await this.deleteSession(sessionId); + } + + async hasSession(sessionId: string): Promise { + const node = this.items.get(sessionId); + if (!node) return false; + + // Check if expired + if (this.ttlMs > 0 && node.expiry <= Date.now()) { + await this.deleteSession(sessionId); + return false; + } + + return true; + } + + async getOrCreateStagehand( + sessionId: string, + ctx: RequestContext, + ): Promise { + const node = this.items.get(sessionId); + + if (!node) { + throw new Error(`Session not found: ${sessionId}`); + } + + // Check if expired + if (this.ttlMs > 0 && node.expiry <= Date.now()) { + await this.deleteSession(sessionId); + throw new Error(`Session expired: ${sessionId}`); + } + + // Bump to most recently used + this.bumpNode(node); + + // Update logger reference for this request + if (ctx.logger) { + node.loggerRef.current = ctx.logger; + } + + // If V3 instance exists, return it + if (node.stagehand) { + return node.stagehand; + } + + // Create V3 instance (lazy initialization) + const options = this.buildV3Options(node.params, ctx, node.loggerRef); + + // Import V3 dynamically to avoid circular dependency + const { V3: V3Class } = await import("../v3"); + const stagehand = new V3Class(options); + await stagehand.init(); + + node.stagehand = stagehand; + return stagehand; + } + + /** + * Build V3Options from stored params and request context + */ + private buildV3Options( + params: CreateSessionParams, + ctx: RequestContext, + loggerRef: { current?: (message: LogLine) => void }, + ): V3Options { + const options: V3Options = { + env: "LOCAL", + model: { + modelName: params.modelName as any, + apiKey: ctx.modelApiKey, + }, + verbose: params.verbose, + systemPrompt: params.systemPrompt, + selfHeal: params.selfHeal, + domSettleTimeout: params.domSettleTimeoutMs, + experimental: params.experimental, + // Wrap logger to use the ref so it can be updated per-request + logger: (message: LogLine) => { + if (loggerRef.current) { + loggerRef.current(message); + } + }, + }; + + // If browserbaseSessionID is provided, set it up + if (params.browserbaseSessionID) { + options.browserbaseSessionID = params.browserbaseSessionID; + } + + return options; + } + + async createSession( + sessionId: string, + params: CreateSessionParams, + ): Promise { + // Check if already exists + if (this.items.has(sessionId)) { + throw new Error(`Session already exists: ${sessionId}`); + } + + // Evict LRU if at capacity + if (this.maxCapacity > 0 && this.items.size >= this.maxCapacity) { + await this.evictLru(); + } + + // Create new node + const node: LruNode = { + sessionId, + params, + stagehand: null, // Lazy initialization + loggerRef: {}, + expiry: this.ttlMs > 0 ? Date.now() + this.ttlMs : Infinity, + prev: this.last, + next: null, + }; + + this.items.set(sessionId, node); + + // Link to end of list + if (this.last) this.last.next = node; + this.last = node; + if (!this.first) this.first = node; + } + + async deleteSession(sessionId: string): Promise { + const node = this.items.get(sessionId); + if (!node) return; + + // Close V3 instance if it exists + if (node.stagehand) { + try { + await node.stagehand.close(); + } catch (error) { + console.error(`Error closing stagehand for session ${sessionId}:`, error); + } + } + + // Remove from map + this.items.delete(sessionId); + + // Unlink from list + const { prev, next } = node; + if (prev) prev.next = next; + if (next) next.prev = prev; + if (this.first === node) this.first = next; + if (this.last === node) this.last = prev; + } + + updateCacheConfig(config: SessionCacheConfig): void { + if (config.maxCapacity !== undefined) { + if (config.maxCapacity <= 0) { + throw new Error("Max capacity must be greater than 0"); + } + const previousCapacity = this.maxCapacity; + this.maxCapacity = config.maxCapacity; + + // Evict excess if new capacity is smaller + if (this.maxCapacity < previousCapacity) { + const excess = this.items.size - this.maxCapacity; + for (let i = 0; i < excess; i++) { + // Fire and forget - don't await to match cloud behavior + this.evictLru().catch(console.error); + } + } + } + + if (config.ttlMs !== undefined) { + this.ttlMs = config.ttlMs; + } + } + + getCacheConfig(): SessionCacheConfig { + return { + maxCapacity: this.maxCapacity, + ttlMs: this.ttlMs, + }; + } + + async destroy(): Promise { + // Stop cleanup interval + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + // Close all V3 instances + const sessionIds = Array.from(this.items.keys()); + await Promise.all(sessionIds.map((id) => this.deleteSession(id))); + } + + /** + * Get the number of cached sessions + */ + get size(): number { + return this.items.size; + } +} diff --git a/packages/core/lib/v3/server/SessionStore.ts b/packages/core/lib/v3/server/SessionStore.ts new file mode 100644 index 000000000..ddcab03da --- /dev/null +++ b/packages/core/lib/v3/server/SessionStore.ts @@ -0,0 +1,174 @@ +import type { V3Options, LogLine } from "../types/public"; +import type { V3 } from "../v3"; + +/** + * Parameters for creating a new session. + * This is what gets persisted - a subset of StartSessionParams + * that excludes runtime-only values like modelApiKey. + * + * Includes cloud-specific fields that pass through to cloud implementations. + * The library ignores fields it doesn't need, but they're available to SessionStore. + */ +export interface CreateSessionParams { + /** Model name (e.g., "openai/gpt-4o") */ + modelName: string; + /** Verbosity level */ + verbose?: 0 | 1 | 2; + /** Custom system prompt */ + systemPrompt?: string; + /** Enable self-healing for failed actions */ + selfHeal?: boolean; + /** DOM settle timeout in milliseconds */ + domSettleTimeoutMs?: number; + /** Enable experimental features */ + experimental?: boolean; + + // Browserbase-specific (used by cloud implementations) + /** Browserbase API key */ + browserbaseApiKey?: string; + /** Browserbase project ID */ + browserbaseProjectId?: string; + /** Existing Browserbase session ID to connect to */ + browserbaseSessionID?: string; + /** Wait for captcha solves */ + waitForCaptchaSolves?: boolean; + /** Browserbase session creation params */ + browserbaseSessionCreateParams?: Record; + + // Cloud-specific metadata fields + /** Debug DOM mode */ + debugDom?: boolean; + /** Act timeout in milliseconds */ + actTimeoutMs?: number; + /** Client language (typescript, python, playground) */ + clientLanguage?: string; + /** SDK version */ + sdkVersion?: string; +} + +/** + * Request-time context passed when resolving a session. + * Contains values that come from request headers rather than storage. + */ +export interface RequestContext { + /** Model API key (from x-model-api-key header) */ + modelApiKey?: string; + /** Logger function for this request */ + logger?: (message: LogLine) => void; +} + +/** + * Configuration options for session cache behavior. + */ +export interface SessionCacheConfig { + /** Maximum number of sessions to cache. Default: 100 */ + maxCapacity?: number; + /** TTL for cached sessions in milliseconds. Default: 300000 (5 minutes) */ + ttlMs?: number; +} + +/** + * Result of starting a session. + */ +export interface StartSessionResult { + /** The session ID (may be generated or provided) */ + sessionId: string; + /** Whether the session is available for use */ + available: boolean; +} + +/** + * SessionStore interface for managing session lifecycle and V3 instances. + * + * The library provides an InMemorySessionStore as the default implementation + * with full caching support (TTL, LRU eviction, etc.). + * + * Cloud environments can implement this interface to: + * - Persist session config to a database + * - Use custom caching strategies (e.g., LaunchDarkly-driven config) + * - Add eviction hooks for cleanup + * - Handle platform-specific session lifecycle (e.g., Browserbase) + * + * This enables stateless pod architectures where any pod can handle any request. + */ +export interface SessionStore { + /** + * Start a new session. + * + * This is the main entry point for session creation. Implementations can: + * - Create platform-specific resources (e.g., Browserbase session) + * - Persist session config to storage + * - Check feature flags for availability + * + * @param params - Session configuration + * @returns Session ID and availability status + */ + startSession(params: CreateSessionParams): Promise; + + /** + * End a session and cleanup all resources. + * + * This is the main entry point for session cleanup. Implementations can: + * - Close platform-specific resources (e.g., Browserbase session) + * - Evict V3 instance from cache + * - Update session status in storage + * + * @param sessionId - The session identifier + */ + endSession(sessionId: string): Promise; + + /** + * Check if a session exists. + * @param sessionId - The session identifier + * @returns true if the session exists + */ + hasSession(sessionId: string): Promise; + + /** + * Get or create a V3 instance for a session. + * + * This method handles: + * - Checking the cache for an existing V3 instance + * - On cache miss: loading config, creating V3, caching it + * - Updating the logger reference for streaming + * + * @param sessionId - The session identifier + * @param ctx - Request-time context containing values from headers + * @returns The V3 instance ready for use + * @throws Error if session not found + */ + getOrCreateStagehand(sessionId: string, ctx: RequestContext): Promise; + + /** + * Create a new session with the given parameters. + * Lower-level than startSession - just stores the config. + * @param sessionId - The session identifier + * @param params - Session configuration to persist + */ + createSession(sessionId: string, params: CreateSessionParams): Promise; + + /** + * Delete a session from cache and close V3 instance. + * Lower-level than endSession - just handles cache cleanup. + * @param sessionId - The session identifier + */ + deleteSession(sessionId: string): Promise; + + /** + * Update cache configuration dynamically. + * @param config - New cache configuration values + */ + updateCacheConfig?(config: SessionCacheConfig): void; + + /** + * Get current cache configuration. + * @returns Current cache config + */ + getCacheConfig?(): SessionCacheConfig; + + /** + * Cleanup all resources (close all V3 instances, stop timers). + * Called when shutting down the server. + */ + destroy(): Promise; +} diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 79fa0dadd..aa0bd37d1 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -12,10 +12,12 @@ import type { Action, AgentResult, ModelConfiguration, + LogLine, } from "../types/public"; import type { StagehandZodSchema } from "../zodCompat"; import { jsonSchemaToZod, type JsonSchema } from "../../utils"; -import { SessionManager } from "./sessions"; +import type { SessionStore, RequestContext, CreateSessionParams, SessionCacheConfig } from "./SessionStore"; +import { InMemorySessionStore } from "./InMemorySessionStore"; import { createStreamingResponse } from "./stream"; import { actSchemaV3, @@ -32,13 +34,60 @@ import type { } from "./events"; import { StagehandEventBus, createEventBus } from "../eventBus"; +// ============================================================================= +// Generic HTTP interfaces for cross-version Fastify compatibility +// ============================================================================= + +/** + * Generic HTTP request interface. + * Structurally compatible with FastifyRequest from any version. + */ +export interface StagehandHttpRequest { + headers: Record; + body: unknown; + params: unknown; +} + +/** + * Generic HTTP reply interface. + * Structurally compatible with FastifyReply from any version. + */ +export interface StagehandHttpReply { + status(code: number): StagehandHttpReply; + send(payload: unknown): Promise | unknown; + raw: { + write(chunk: string | Buffer): boolean; + end(): void; + on(event: string, handler: (...args: unknown[]) => void): unknown; + }; + sent: boolean; + hijack(): void; +} + // Re-export event types for consumers export * from "./events"; +// Re-export SessionStore types +export type { SessionStore, RequestContext, CreateSessionParams, SessionCacheConfig, StartSessionResult } from "./SessionStore"; +export { InMemorySessionStore } from "./InMemorySessionStore"; + +// Re-export API schemas and types for consumers +export * from "./schemas"; + export interface StagehandServerOptions { port?: number; host?: string; - sessionTTL?: number; + /** + * Session store for managing session lifecycle and V3 instances. + * Defaults to InMemorySessionStore if not provided. + * Cloud environments should provide a database-backed implementation. + */ + sessionStore?: SessionStore; + /** + * Cache configuration for the default InMemorySessionStore. + * Ignored if a custom sessionStore is provided. + */ + cacheConfig?: SessionCacheConfig; /** Optional: shared event bus instance. If not provided, a new one will be created. */ eventBus?: StagehandEventBus; } @@ -49,11 +98,14 @@ export interface StagehandServerOptions { * 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 SessionStore interface for session management, allowing cloud environments + * to provide database-backed implementations for stateless pod architectures. + * * Uses a shared event bus to allow cloud servers to hook into lifecycle events. */ export class StagehandServer { private app: FastifyInstance; - private sessionManager: SessionManager; + private sessionStore: SessionStore; private port: number; private host: string; private isListening: boolean = false; @@ -63,7 +115,7 @@ export class StagehandServer { 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.sessionStore = options.sessionStore ?? new InMemorySessionStore(options.cacheConfig); this.app = Fastify({ logger: false, // Disable Fastify's built-in logger for cleaner output }); @@ -72,6 +124,13 @@ export class StagehandServer { this.setupRoutes(); } + /** + * Get the session store instance + */ + getSessionStore(): SessionStore { + return this.sessionStore; + } + /** * Emit an event and wait for all async listeners to complete */ @@ -95,7 +154,7 @@ export class StagehandServer { private setupRoutes(): void { // Health check this.app.get("/health", async () => { - return { status: "ok", sessions: this.sessionManager.getActiveSessions().length }; + return { status: "ok" }; }); // Start session - creates a new V3 instance @@ -156,8 +215,8 @@ export class StagehandServer { * Handle /sessions/start - Create new session */ async handleStartSession( - request: FastifyRequest, - reply: FastifyReply, + request: StagehandHttpRequest, + reply: StagehandHttpReply, ): Promise { const requestId = randomUUID(); const startTime = Date.now(); @@ -183,38 +242,39 @@ export class StagehandServer { bodySize: JSON.stringify(request.body).length, }); - const params = request.body as StartSessionParams; - - // Derive V3Options from StartSessionParams — this mirrors what the - // cloud API does, and makes the P2P server a drop-in replacement. - const modelConfig: ModelConfiguration = { - modelName: params.modelName as any, - apiKey: params.modelApiKey, + const body = request.body as StartSessionParams; + + // Build session params from body + headers + // Body contains most params, headers provide credentials and metadata + const createParams: CreateSessionParams = { + // Core params from body + modelName: body.modelName, + verbose: body.verbose as 0 | 1 | 2, + systemPrompt: body.systemPrompt, + selfHeal: body.selfHeal, + domSettleTimeoutMs: body.domSettleTimeoutMs, + experimental: body.experimental, + waitForCaptchaSolves: body.waitForCaptchaSolves, + browserbaseSessionID: body.browserbaseSessionID ?? body.sessionId, + browserbaseSessionCreateParams: body.browserbaseSessionCreateParams, + debugDom: body.debugDom, + actTimeoutMs: body.actTimeoutMs, + // Credentials from headers + browserbaseApiKey: request.headers["x-bb-api-key"] as string | undefined, + browserbaseProjectId: request.headers["x-bb-project-id"] as string | undefined, + // Metadata from headers + clientLanguage: request.headers["x-language"] as string | undefined, + sdkVersion: request.headers["x-sdk-version"] as string | undefined, }; - const v3Config: V3Options = { - env: "LOCAL", - model: modelConfig, - systemPrompt: params.systemPrompt, - domSettleTimeout: params.domSettleTimeoutMs, - verbose: params.verbose as 0 | 1 | 2, - selfHeal: params.selfHeal, - }; - - // If an external sessionId is provided (e.g. from a cloud session store), - // use it as the in-memory key so cloud and library share the same ID. - const externalSessionId = params.sessionId ?? params.browserbaseSessionID; - - const sessionId = this.sessionManager.createSession( - v3Config, - externalSessionId, - ); + // Start session via the store (handles platform-specific logic) + const result = await this.sessionStore.startSession(createParams); // Emit request completed event await this.emitAsync("StagehandRequestCompleted", { type: "StagehandRequestCompleted", timestamp: new Date(), - sessionId, + sessionId: result.sessionId, requestId, statusCode: 200, durationMs: Date.now() - startTime, @@ -224,8 +284,8 @@ export class StagehandServer { reply.status(200).send({ success: true, data: { - sessionId, - available: true, + sessionId: result.sessionId, + available: result.available, }, }); } catch (error) { @@ -250,10 +310,10 @@ export class StagehandServer { * Handle /sessions/:id/act - Execute act command */ async handleAct( - request: FastifyRequest<{ Params: { id: string } }>, - reply: FastifyReply, + request: StagehandHttpRequest, + reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params; + const { id: sessionId } = request.params as { id: string }; const requestId = randomUUID(); const startTime = Date.now(); @@ -276,7 +336,7 @@ export class StagehandServer { bodySize: JSON.stringify(request.body).length, }); - if (!this.sessionManager.hasSession(sessionId)) { + if (!(await this.sessionStore.hasSession(sessionId))) { await this.emitAsync("StagehandRequestCompleted", { type: "StagehandRequestCompleted", timestamp: new Date(), @@ -292,16 +352,22 @@ export class StagehandServer { // Validate request body const data = actSchemaV3.parse(request.body); + // Build request context + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; + await createStreamingResponse>({ sessionId, requestId, actionType: "act", - sessionManager: this.sessionManager, + sessionStore: this.sessionStore, + requestContext: ctx, request, reply, eventBus: this.eventBus, - handler: async (ctx, data) => { - const stagehand = ctx.stagehand as any; + handler: async (handlerCtx, data) => { + const stagehand = handlerCtx.stagehand as any; const { frameId } = data; // Get the page @@ -371,10 +437,10 @@ export class StagehandServer { * Handle /sessions/:id/extract - Execute extract command */ async handleExtract( - request: FastifyRequest<{ Params: { id: string } }>, - reply: FastifyReply, + request: StagehandHttpRequest, + reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params; + const { id: sessionId } = request.params as { id: string }; const requestId = randomUUID(); const startTime = Date.now(); @@ -396,7 +462,7 @@ export class StagehandServer { bodySize: JSON.stringify(request.body).length, }); - if (!this.sessionManager.hasSession(sessionId)) { + if (!(await this.sessionStore.hasSession(sessionId))) { await this.emitAsync("StagehandRequestCompleted", { type: "StagehandRequestCompleted", timestamp: new Date(), @@ -411,16 +477,21 @@ export class StagehandServer { try { const data = extractSchemaV3.parse(request.body); + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; + await createStreamingResponse>({ sessionId, requestId, actionType: "extract", - sessionManager: this.sessionManager, + sessionStore: this.sessionStore, + requestContext: ctx, request, reply, eventBus: this.eventBus, - handler: async (ctx, data) => { - const stagehand = ctx.stagehand as any; + handler: async (handlerCtx, data) => { + const stagehand = handlerCtx.stagehand as any; const { frameId } = data; const page = frameId @@ -499,10 +570,10 @@ export class StagehandServer { * Handle /sessions/:id/observe - Execute observe command */ async handleObserve( - request: FastifyRequest<{ Params: { id: string } }>, - reply: FastifyReply, + request: StagehandHttpRequest, + reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params; + const { id: sessionId } = request.params as { id: string }; const requestId = randomUUID(); const startTime = Date.now(); @@ -524,7 +595,7 @@ export class StagehandServer { bodySize: JSON.stringify(request.body).length, }); - if (!this.sessionManager.hasSession(sessionId)) { + if (!(await this.sessionStore.hasSession(sessionId))) { await this.emitAsync("StagehandRequestCompleted", { type: "StagehandRequestCompleted", timestamp: new Date(), @@ -539,16 +610,21 @@ export class StagehandServer { try { const data = observeSchemaV3.parse(request.body); + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; + await createStreamingResponse>({ sessionId, requestId, actionType: "observe", - sessionManager: this.sessionManager, + sessionStore: this.sessionStore, + requestContext: ctx, request, reply, eventBus: this.eventBus, - handler: async (ctx, data) => { - const stagehand = ctx.stagehand as any; + handler: async (handlerCtx, data) => { + const stagehand = handlerCtx.stagehand as any; const { frameId } = data; const page = frameId @@ -616,10 +692,10 @@ export class StagehandServer { * Handle /sessions/:id/agentExecute - Execute agent command */ async handleAgentExecute( - request: FastifyRequest<{ Params: { id: string } }>, - reply: FastifyReply, + request: StagehandHttpRequest, + reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params; + const { id: sessionId } = request.params as { id: string }; const requestId = randomUUID(); const startTime = Date.now(); @@ -641,7 +717,7 @@ export class StagehandServer { bodySize: JSON.stringify(request.body).length, }); - if (!this.sessionManager.hasSession(sessionId)) { + if (!(await this.sessionStore.hasSession(sessionId))) { await this.emitAsync("StagehandRequestCompleted", { type: "StagehandRequestCompleted", timestamp: new Date(), @@ -656,16 +732,21 @@ export class StagehandServer { try { const data = agentExecuteSchemaV3.parse(request.body); + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; + await createStreamingResponse>({ sessionId, requestId, actionType: "agentExecute", - sessionManager: this.sessionManager, + sessionStore: this.sessionStore, + requestContext: ctx, request, reply, eventBus: this.eventBus, - handler: async (ctx, data) => { - const stagehand = ctx.stagehand as any; + handler: async (handlerCtx, data) => { + const stagehand = handlerCtx.stagehand as any; const { agentConfig, executeOptions, frameId } = data; const page = frameId @@ -721,10 +802,10 @@ export class StagehandServer { * Handle /sessions/:id/navigate - Navigate to URL */ async handleNavigate( - request: FastifyRequest<{ Params: { id: string } }>, - reply: FastifyReply, + request: StagehandHttpRequest, + reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params; + const { id: sessionId } = request.params as { id: string }; const requestId = randomUUID(); const startTime = Date.now(); @@ -746,7 +827,7 @@ export class StagehandServer { bodySize: JSON.stringify(request.body).length, }); - if (!this.sessionManager.hasSession(sessionId)) { + if (!(await this.sessionStore.hasSession(sessionId))) { await this.emitAsync("StagehandRequestCompleted", { type: "StagehandRequestCompleted", timestamp: new Date(), @@ -759,16 +840,21 @@ export class StagehandServer { } try { + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; + await createStreamingResponse({ sessionId, requestId, actionType: "navigate", - sessionManager: this.sessionManager, + sessionStore: this.sessionStore, + requestContext: ctx, request, reply, eventBus: this.eventBus, - handler: async (ctx, data: any) => { - const stagehand = ctx.stagehand as any; + handler: async (handlerCtx, data: any) => { + const stagehand = handlerCtx.stagehand as any; const { url, options, frameId } = data; if (!url) { @@ -821,10 +907,10 @@ export class StagehandServer { * Handle /sessions/:id/end - End session and cleanup */ async handleEndSession( - request: FastifyRequest<{ Params: { id: string } }>, - reply: FastifyReply, + request: StagehandHttpRequest, + reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params; + const { id: sessionId } = request.params as { id: string }; const requestId = randomUUID(); const startTime = Date.now(); @@ -847,7 +933,16 @@ export class StagehandServer { }); try { - await this.sessionManager.endSession(sessionId, "manual"); + // End session (handles platform cleanup + cache eviction) + await this.sessionStore.endSession(sessionId); + + // Emit session ended event + await this.eventBus.emitAsync("StagehandSessionEnded", { + type: "StagehandSessionEnded", + timestamp: new Date(), + sessionId, + reason: "manual", + }); await this.emitAsync("StagehandRequestCompleted", { type: "StagehandRequestCompleted", @@ -926,7 +1021,9 @@ export class StagehandServer { await this.app.close(); this.isListening = false; } - await this.sessionManager.destroy(); + + // Cleanup session store + await this.sessionStore.destroy(); } /** @@ -940,9 +1037,12 @@ export class StagehandServer { } /** - * Get active session count + * Subscribe to server events */ - getActiveSessionCount(): number { - return this.sessionManager.getActiveSessions().length; + on( + event: K, + listener: (data: StagehandServerEventMap[K]) => void | Promise, + ): void { + this.eventBus.on(event, listener); } } diff --git a/packages/core/lib/v3/server/schemas.ts b/packages/core/lib/v3/server/schemas.ts index dad9882f3..931753299 100644 --- a/packages/core/lib/v3/server/schemas.ts +++ b/packages/core/lib/v3/server/schemas.ts @@ -1,10 +1,89 @@ import { z } from "zod"; /** - * Shared Zod schemas for Stagehand P2P Server API - * These schemas are used for both runtime validation and OpenAPI generation + * Shared Zod schemas for Stagehand Server API + * These schemas define the complete API contract between SDK clients and the server. + * Used for runtime validation, type inference, and OpenAPI generation. */ +// ============================================================================= +// Common Schemas +// ============================================================================= + +/** Standard API success response wrapper */ +export const successResponseSchema = (dataSchema: T) => + z.object({ + success: z.literal(true), + data: dataSchema, + }); + +/** Standard API error response */ +export const errorResponseSchema = z.object({ + success: z.literal(false), + message: z.string(), +}); + +/** Model configuration for LLM calls */ +export const modelConfigSchema = z.object({ + provider: z.string().optional(), + model: z.string().optional(), + apiKey: z.string().optional(), + baseURL: z.string().url().optional(), +}); + +// ============================================================================= +// Request Headers Schema +// ============================================================================= + +/** Headers expected on API requests */ +export const requestHeadersSchema = z.object({ + "x-bb-api-key": z.string().optional(), + "x-bb-project-id": z.string().optional(), + "x-model-api-key": z.string().optional(), + "x-sdk-version": z.string().optional(), + "x-language": z.enum(["typescript", "python", "playground"]).optional(), + "x-stream-response": z.string().optional(), + "x-sent-at": z.string().optional(), +}); + +// ============================================================================= +// Session Schemas +// ============================================================================= + +/** POST /v1/sessions/start - Request body */ +export const startSessionRequestSchema = z.object({ + modelName: z.string(), + domSettleTimeoutMs: z.number().optional(), + verbose: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), + systemPrompt: z.string().optional(), + selfHeal: z.boolean().optional(), + browserbaseSessionID: z.string().optional(), + sessionId: z.string().optional(), // Alias for browserbaseSessionID + browserbaseSessionCreateParams: z.record(z.string(), z.unknown()).optional(), + waitForCaptchaSolves: z.boolean().optional(), + experimental: z.boolean().optional(), + debugDom: z.boolean().optional(), + actTimeoutMs: z.number().optional(), +}); + +/** POST /v1/sessions/start - Response data */ +export const startSessionResponseDataSchema = z.object({ + sessionId: z.string().nullable(), + available: z.boolean(), +}); + +/** POST /v1/sessions/start - Full response */ +export const startSessionResponseSchema = successResponseSchema(startSessionResponseDataSchema); + +/** POST /v1/sessions/:id/end - Response */ +export const endSessionResponseSchema = z.object({ + success: z.literal(true), +}); + +// ============================================================================= +// Action Request Schemas (V3 API) +// ============================================================================= + // Zod schemas for V3 API (we only support V3 in the library server) export const actSchemaV3 = z.object({ input: z.string().or( @@ -107,3 +186,79 @@ export const navigateSchemaV3 = z.object({ .optional(), frameId: z.string().optional(), }); + +// ============================================================================= +// Action Response Schemas +// ============================================================================= + +/** Action schema - represents a single observable action */ +export const actionSchema = z.object({ + selector: z.string(), + description: z.string(), + backendNodeId: z.number().optional(), + method: z.string().optional(), + arguments: z.array(z.string()).optional(), +}); + +/** Act result schema */ +export const actResultSchema = z.object({ + success: z.boolean(), + message: z.string().optional(), + action: z.string().optional(), +}); + +/** Extract result schema - dynamic based on user's schema */ +export const extractResultSchema = z.record(z.string(), z.unknown()); + +/** Observe result schema - array of actions */ +export const observeResultSchema = z.array(actionSchema); + +/** Agent result schema */ +export const agentResultSchema = z.object({ + success: z.boolean(), + message: z.string().optional(), + actions: z.array(z.unknown()).optional(), + completed: z.boolean().optional(), +}); + +/** Navigate result schema */ +export const navigateResultSchema = z.object({ + url: z.string().optional(), + status: z.number().optional(), +}); + +// ============================================================================= +// Route Parameter Schemas +// ============================================================================= + +/** Route params for /sessions/:id/* routes */ +export const sessionIdParamsSchema = z.object({ + id: z.string(), +}); + +// ============================================================================= +// Inferred Types +// ============================================================================= + +// Request types +export type StartSessionRequest = z.infer; +export type ActRequest = z.infer; +export type ExtractRequest = z.infer; +export type ObserveRequest = z.infer; +export type AgentExecuteRequest = z.infer; +export type NavigateRequest = z.infer; + +// Response types +export type StartSessionResponseData = z.infer; +export type ActResult = z.infer; +export type ExtractResult = z.infer; +export type ObserveResult = z.infer; +export type AgentResult = z.infer; +export type NavigateResult = z.infer; +export type Action = z.infer; + +// Header types +export type RequestHeaders = z.infer; + +// Route param types +export type SessionIdParams = z.infer; diff --git a/packages/core/lib/v3/server/sessions.ts b/packages/core/lib/v3/server/sessions.ts deleted file mode 100644 index 4b3c5f43c..000000000 --- a/packages/core/lib/v3/server/sessions.ts +++ /dev/null @@ -1,207 +0,0 @@ -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, sessionIdOverride?: string): string { - const sessionId = sessionIdOverride ?? randomUUID(); - - if (this.sessions.has(sessionId)) { - throw new Error(`Session already exists with id: ${sessionId}`); - } - - this.sessions.set(sessionId, { - sessionId, - stagehand: null, // Will be created on first use - config, - loggerRef: {}, - createdAt: new Date(), - }); - - 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 index 8fd45cb6b..f6eb414b1 100644 --- a/packages/core/lib/v3/server/stream.ts +++ b/packages/core/lib/v3/server/stream.ts @@ -1,7 +1,6 @@ -import type { FastifyReply, FastifyRequest } from "fastify"; import { randomUUID } from "crypto"; import type { V3 } from "../v3"; -import type { SessionManager } from "./sessions"; +import type { SessionStore, RequestContext } from "./SessionStore"; import type { StagehandEventBus } from "../eventBus"; import type { StagehandActionStartedEvent, @@ -13,6 +12,29 @@ import type { StagehandActionProgressEvent, } from "./events"; +/** + * Generic HTTP request interface for streaming. + * Structurally compatible with FastifyRequest from any version. + */ +export interface StreamingHttpRequest { + headers: Record; + body: unknown; +} + +/** + * Generic HTTP reply interface for streaming. + * Structurally compatible with FastifyReply from any version. + */ +export interface StreamingHttpReply { + status(code: number): StreamingHttpReply; + send(payload: unknown): Promise | unknown; + raw: { + writeHead(statusCode: number, headers: Record): void; + write(chunk: string | Buffer): boolean; + end(): void; + }; +} + export interface StreamingHandlerResult { result: unknown; } @@ -21,7 +43,7 @@ export interface StreamingHandlerContext { stagehand: V3; sessionId: string; requestId: string; - request: FastifyRequest; + request: StreamingHttpRequest; actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; eventBus: StagehandEventBus; } @@ -30,9 +52,10 @@ export interface StreamingResponseOptions { sessionId: string; requestId: string; actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; - sessionManager: SessionManager; - request: FastifyRequest; - reply: FastifyReply; + sessionStore: SessionStore; + requestContext: RequestContext; + request: StreamingHttpRequest; + reply: StreamingHttpReply; eventBus: StagehandEventBus; handler: (ctx: StreamingHandlerContext, data: T) => Promise; } @@ -41,7 +64,7 @@ export interface StreamingResponseOptions { * Sends an SSE (Server-Sent Events) message to the client */ async function sendSSE( - reply: FastifyReply, + reply: StreamingHttpReply, data: object, eventBus: StagehandEventBus, sessionId: string, @@ -72,7 +95,8 @@ export async function createStreamingResponse({ sessionId, requestId, actionType, - sessionManager, + sessionStore, + requestContext, request, reply, eventBus, @@ -126,10 +150,10 @@ export async function createStreamingResponse({ let actionId: string | undefined = undefined; try { - // Get or create the Stagehand instance with dynamic logger - const stagehand = await sessionManager.getStagehand( - sessionId, - shouldStream + // Build request context with streaming logger if needed + const ctxWithLogger: RequestContext = { + ...requestContext, + logger: shouldStream ? async (message) => { await sendSSE( reply, @@ -157,7 +181,10 @@ export async function createStreamingResponse({ }); } : undefined, - ); + }; + + // Get or create the Stagehand instance from the session store + const stagehand = await sessionStore.getOrCreateStagehand(sessionId, ctxWithLogger); if (shouldStream) { await sendSSE( diff --git a/packages/core/lib/v3/types/private/api.ts b/packages/core/lib/v3/types/private/api.ts index 3b059cf82..1d503f0d1 100644 --- a/packages/core/lib/v3/types/private/api.ts +++ b/packages/core/lib/v3/types/private/api.ts @@ -42,6 +42,13 @@ export interface StartSessionParams extends Partial { > & { projectId?: string }; selfHeal?: boolean; browserbaseSessionID?: string; + waitForCaptchaSolves?: boolean; + experimental?: boolean; + // Cloud-specific metadata fields + debugDom?: boolean; + actTimeoutMs?: number; + clientLanguage?: string; + sdkVersion?: string; } export interface StartSessionResult { From a4ff394e2443d10c467b4ecb8126bd4120d69c6b Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:07:14 -0800 Subject: [PATCH 11/30] cleanup schemas --- packages/core/lib/v3/server/SessionStore.ts | 11 +---- packages/core/lib/v3/server/index.ts | 15 ++----- packages/core/lib/v3/server/schemas.ts | 46 ++++++--------------- packages/core/lib/v3/types/private/api.ts | 6 +-- 4 files changed, 21 insertions(+), 57 deletions(-) diff --git a/packages/core/lib/v3/server/SessionStore.ts b/packages/core/lib/v3/server/SessionStore.ts index ddcab03da..6ce4717bb 100644 --- a/packages/core/lib/v3/server/SessionStore.ts +++ b/packages/core/lib/v3/server/SessionStore.ts @@ -67,15 +67,8 @@ export interface SessionCacheConfig { ttlMs?: number; } -/** - * Result of starting a session. - */ -export interface StartSessionResult { - /** The session ID (may be generated or provided) */ - sessionId: string; - /** Whether the session is available for use */ - available: boolean; -} +// Re-export StartSessionResult from schemas (defined as Zod schema) +export type { StartSessionResult } from "./schemas"; /** * SessionStore interface for managing session lifecycle and V3 instances. diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index aa0bd37d1..37e1b508f 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -3,7 +3,6 @@ import cors from "@fastify/cors"; import { z } from "zod"; import { randomUUID } from "crypto"; import type { - V3Options, ActOptions, ActResult, ExtractResult, @@ -12,11 +11,10 @@ import type { Action, AgentResult, ModelConfiguration, - LogLine, } from "../types/public"; import type { StagehandZodSchema } from "../zodCompat"; import { jsonSchemaToZod, type JsonSchema } from "../../utils"; -import type { SessionStore, RequestContext, CreateSessionParams, SessionCacheConfig } from "./SessionStore"; +import type { SessionStore, RequestContext, CreateSessionParams } from "./SessionStore"; import { InMemorySessionStore } from "./InMemorySessionStore"; import { createStreamingResponse } from "./stream"; import { @@ -68,7 +66,7 @@ export interface StagehandHttpReply { export * from "./events"; // Re-export SessionStore types -export type { SessionStore, RequestContext, CreateSessionParams, SessionCacheConfig, StartSessionResult } from "./SessionStore"; +export type { SessionStore, RequestContext, CreateSessionParams, StartSessionResult } from "./SessionStore"; export { InMemorySessionStore } from "./InMemorySessionStore"; // Re-export API schemas and types for consumers @@ -83,11 +81,6 @@ export interface StagehandServerOptions { * Cloud environments should provide a database-backed implementation. */ sessionStore?: SessionStore; - /** - * Cache configuration for the default InMemorySessionStore. - * Ignored if a custom sessionStore is provided. - */ - cacheConfig?: SessionCacheConfig; /** Optional: shared event bus instance. If not provided, a new one will be created. */ eventBus?: StagehandEventBus; } @@ -111,11 +104,11 @@ export class StagehandServer { private isListening: boolean = false; private eventBus: StagehandEventBus; - constructor(options: StagehandServerOptions) { + constructor(options: StagehandServerOptions = {}) { this.eventBus = options.eventBus || createEventBus(); this.port = options.port || 3000; this.host = options.host || "0.0.0.0"; - this.sessionStore = options.sessionStore ?? new InMemorySessionStore(options.cacheConfig); + this.sessionStore = options.sessionStore ?? new InMemorySessionStore(); this.app = Fastify({ logger: false, // Disable Fastify's built-in logger for cleaner output }); diff --git a/packages/core/lib/v3/server/schemas.ts b/packages/core/lib/v3/server/schemas.ts index 931753299..868a44317 100644 --- a/packages/core/lib/v3/server/schemas.ts +++ b/packages/core/lib/v3/server/schemas.ts @@ -17,13 +17,7 @@ export const successResponseSchema = (dataSchema: T) => data: dataSchema, }); -/** Standard API error response */ -export const errorResponseSchema = z.object({ - success: z.literal(false), - message: z.string(), -}); - -/** Model configuration for LLM calls */ +/** Model configuration for LLM calls (used in action options) */ export const modelConfigSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), @@ -66,13 +60,19 @@ export const startSessionRequestSchema = z.object({ actTimeoutMs: z.number().optional(), }); -/** POST /v1/sessions/start - Response data */ +/** Internal result from SessionStore.startSession() - sessionId always present */ +export const startSessionResultSchema = z.object({ + sessionId: z.string(), + available: z.boolean(), +}); + +/** POST /v1/sessions/start - HTTP response data (sessionId can be null when unavailable) */ export const startSessionResponseDataSchema = z.object({ sessionId: z.string().nullable(), available: z.boolean(), }); -/** POST /v1/sessions/start - Full response */ +/** POST /v1/sessions/start - Full HTTP response */ export const startSessionResponseSchema = successResponseSchema(startSessionResponseDataSchema); /** POST /v1/sessions/:id/end - Response */ @@ -97,14 +97,7 @@ export const actSchemaV3 = z.object({ ), options: z .object({ - model: z - .object({ - provider: z.string().optional(), - model: z.string().optional(), - apiKey: z.string().optional(), - baseURL: z.string().url().optional(), - }) - .optional(), + model: modelConfigSchema.optional(), variables: z.record(z.string(), z.string()).optional(), timeout: z.number().optional(), }) @@ -117,14 +110,7 @@ export const extractSchemaV3 = z.object({ 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(), + model: modelConfigSchema.optional(), timeout: z.number().optional(), selector: z.string().optional(), }) @@ -136,14 +122,7 @@ 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(), + model: modelConfigSchema.optional(), timeout: z.number().optional(), selector: z.string().optional(), }) @@ -249,6 +228,7 @@ export type AgentExecuteRequest = z.infer; export type NavigateRequest = z.infer; // Response types +export type StartSessionResult = z.infer; export type StartSessionResponseData = z.infer; export type ActResult = z.infer; export type ExtractResult = z.infer; diff --git a/packages/core/lib/v3/types/private/api.ts b/packages/core/lib/v3/types/private/api.ts index 1d503f0d1..3cf3ce03b 100644 --- a/packages/core/lib/v3/types/private/api.ts +++ b/packages/core/lib/v3/types/private/api.ts @@ -51,10 +51,8 @@ export interface StartSessionParams extends Partial { sdkVersion?: string; } -export interface StartSessionResult { - sessionId: string; - available?: boolean; -} +// Re-export StartSessionResult from schemas (defined as Zod schema) +export type { StartSessionResult } from "../../server/schemas"; export interface SuccessResponse { success: true; From 68e57791c2c1adc340042b0369a6e1df3a23a271 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:16:47 -0800 Subject: [PATCH 12/30] remove unused events --- packages/core/lib/v3/server/events.ts | 211 +----------- packages/core/lib/v3/server/index.ts | 440 +------------------------- packages/core/lib/v3/server/stream.ts | 211 ++---------- 3 files changed, 37 insertions(+), 825 deletions(-) diff --git a/packages/core/lib/v3/server/events.ts b/packages/core/lib/v3/server/events.ts index f1f326f26..2f3c23f95 100644 --- a/packages/core/lib/v3/server/events.ts +++ b/packages/core/lib/v3/server/events.ts @@ -1,183 +1,14 @@ -import type { V3Options, LogLine } from "../types/public"; -import type { FastifyRequest } from "fastify"; - /** * Base event interface - all events extend this */ export interface StagehandServerEvent { timestamp: Date; sessionId?: string; - requestId?: string; // For correlation across events -} - -// ===== SERVER LIFECYCLE EVENTS ===== - -export interface StagehandServerStartedEvent extends StagehandServerEvent { - type: "StagehandServerStarted"; - port: number; - host: string; -} - -export interface StagehandServerReadyEvent extends StagehandServerEvent { - type: "StagehandServerReady"; -} - -export interface StagehandServerShutdownEvent extends StagehandServerEvent { - type: "StagehandServerShutdown"; - graceful: boolean; -} - -// ===== SESSION LIFECYCLE EVENTS ===== - -export interface StagehandSessionResumedEvent extends StagehandServerEvent { - type: "StagehandSessionResumed"; - sessionId: string; - fromCache: boolean; -} - -export interface StagehandSessionInitializedEvent extends StagehandServerEvent { - type: "StagehandSessionInitialized"; - sessionId: string; -} - -export interface StagehandSessionEndedEvent extends StagehandServerEvent { - type: "StagehandSessionEnded"; - sessionId: string; - reason: "manual" | "ttl_expired" | "cache_evicted" | "error"; -} - -// ===== REQUEST LIFECYCLE EVENTS ===== - -export interface StagehandRequestReceivedEvent extends StagehandServerEvent { - type: "StagehandRequestReceived"; - sessionId: string; - requestId: string; - method: string; - path: string; - headers: { - "x-stream-response"?: boolean; - "x-bb-api-key"?: string; - "x-model-api-key"?: string; - "x-sdk-version"?: string; - "x-language"?: string; - "x-sent-at"?: string; - }; - bodySize: number; -} - -export interface StagehandRequestValidatedEvent extends StagehandServerEvent { - type: "StagehandRequestValidated"; - sessionId: string; - requestId: string; - schemaVersion: "v3"; - parsedData: unknown; -} - -export interface StagehandRequestCompletedEvent extends StagehandServerEvent { - type: "StagehandRequestCompleted"; - sessionId: string; - requestId: string; - statusCode: number; - responseSize?: number; - durationMs: number; -} - -// ===== ACTION LIFECYCLE EVENTS ===== - -export interface StagehandActionStartedEvent extends StagehandServerEvent { - type: "StagehandActionStarted"; - sessionId: string; - requestId: string; - actionId?: string; // Will be set by cloud listeners - actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; - input: string | object; - options: object; - url: string; - frameId?: string; -} - -export interface StagehandActionProgressEvent extends StagehandServerEvent { - type: "StagehandActionProgress"; - sessionId: string; - requestId: string; - actionId?: string; - actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; - message: LogLine; -} - -export interface StagehandActionCompletedEvent extends StagehandServerEvent { - type: "StagehandActionCompleted"; - sessionId: string; - requestId: string; - actionId?: string; - actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; - result: unknown; - metrics?: { - promptTokens: number; - completionTokens: number; - inferenceTimeMs: number; - }; - durationMs: number; -} - -export interface StagehandActionErroredEvent extends StagehandServerEvent { - type: "StagehandActionErrored"; - sessionId: string; - requestId: string; - actionId?: string; - actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; - error: { - message: string; - stack?: string; - code?: string; - }; - durationMs: number; -} - -// ===== STREAMING EVENTS ===== - -export interface StagehandStreamStartedEvent extends StagehandServerEvent { - type: "StagehandStreamStarted"; - sessionId: string; - requestId: string; -} - -export interface StagehandStreamMessageSentEvent extends StagehandServerEvent { - type: "StagehandStreamMessageSent"; - sessionId: string; - requestId: string; - messageType: "system" | "log"; - data: unknown; -} - -export interface StagehandStreamEndedEvent extends StagehandServerEvent { - type: "StagehandStreamEnded"; - sessionId: string; - requestId: string; -} - -// ===== CACHE EVENTS ===== - -export interface StagehandCacheHitEvent extends StagehandServerEvent { - type: "StagehandCacheHit"; - sessionId: string; - cacheKey: string; -} - -export interface StagehandCacheMissedEvent extends StagehandServerEvent { - type: "StagehandCacheMissed"; - sessionId: string; - cacheKey: string; -} - -export interface StagehandCacheEvictedEvent extends StagehandServerEvent { - type: "StagehandCacheEvicted"; - sessionId: string; - cacheKey: string; - reason: "lru" | "ttl" | "manual"; + requestId?: string; } // ===== LLM REQUEST/RESPONSE EVENTS ===== +// These are the only events with actual subscribers (used by llmEventBridge.ts and llmEventHandler.ts) export interface StagehandLLMRequestEvent extends StagehandServerEvent { type: "StagehandLLMRequest"; @@ -247,50 +78,12 @@ export interface StagehandLLMErrorEvent extends StagehandServerEvent { // Union type for all events export type StagehandServerEventType = - | StagehandServerStartedEvent - | StagehandServerReadyEvent - | StagehandServerShutdownEvent - | StagehandSessionResumedEvent - | StagehandSessionInitializedEvent - | StagehandSessionEndedEvent - | StagehandRequestReceivedEvent - | StagehandRequestValidatedEvent - | StagehandRequestCompletedEvent - | StagehandActionStartedEvent - | StagehandActionProgressEvent - | StagehandActionCompletedEvent - | StagehandActionErroredEvent - | StagehandStreamStartedEvent - | StagehandStreamMessageSentEvent - | StagehandStreamEndedEvent - | StagehandCacheHitEvent - | StagehandCacheMissedEvent - | StagehandCacheEvictedEvent | StagehandLLMRequestEvent | StagehandLLMResponseEvent | StagehandLLMErrorEvent; // Type-safe event emitter interface export interface StagehandServerEventMap { - StagehandServerStarted: StagehandServerStartedEvent; - StagehandServerReady: StagehandServerReadyEvent; - StagehandServerShutdown: StagehandServerShutdownEvent; - StagehandSessionResumed: StagehandSessionResumedEvent; - StagehandSessionInitialized: StagehandSessionInitializedEvent; - StagehandSessionEnded: StagehandSessionEndedEvent; - StagehandRequestReceived: StagehandRequestReceivedEvent; - StagehandRequestValidated: StagehandRequestValidatedEvent; - StagehandRequestCompleted: StagehandRequestCompletedEvent; - StagehandActionStarted: StagehandActionStartedEvent; - StagehandActionProgress: StagehandActionProgressEvent; - StagehandActionCompleted: StagehandActionCompletedEvent; - StagehandActionErrored: StagehandActionErroredEvent; - StagehandStreamStarted: StagehandStreamStartedEvent; - StagehandStreamMessageSent: StagehandStreamMessageSentEvent; - StagehandStreamEnded: StagehandStreamEndedEvent; - StagehandCacheHit: StagehandCacheHitEvent; - StagehandCacheMissed: StagehandCacheMissedEvent; - StagehandCacheEvicted: StagehandCacheEvictedEvent; StagehandLLMRequest: StagehandLLMRequestEvent; StagehandLLMResponse: StagehandLLMResponseEvent; StagehandLLMError: StagehandLLMErrorEvent; diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 37e1b508f..3b7246d63 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -1,7 +1,6 @@ -import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import Fastify, { FastifyInstance } from "fastify"; import cors from "@fastify/cors"; import { z } from "zod"; -import { randomUUID } from "crypto"; import type { ActOptions, ActResult, @@ -22,15 +21,8 @@ import { extractSchemaV3, observeSchemaV3, agentExecuteSchemaV3, - navigateSchemaV3, } from "./schemas"; import type { StartSessionParams } from "../types/private/api"; -import type { - StagehandServerEventMap, - StagehandRequestReceivedEvent, - StagehandRequestCompletedEvent, -} from "./events"; -import { StagehandEventBus, createEventBus } from "../eventBus"; // ============================================================================= // Generic HTTP interfaces for cross-version Fastify compatibility @@ -62,7 +54,7 @@ export interface StagehandHttpReply { hijack(): void; } -// Re-export event types for consumers +// Re-export event types for consumers (only LLM events are actually used) export * from "./events"; // Re-export SessionStore types @@ -81,8 +73,6 @@ export interface StagehandServerOptions { * Cloud environments should provide a database-backed implementation. */ sessionStore?: SessionStore; - /** Optional: shared event bus instance. If not provided, a new one will be created. */ - eventBus?: StagehandEventBus; } /** @@ -93,8 +83,6 @@ export interface StagehandServerOptions { * * Uses a SessionStore interface for session management, allowing cloud environments * to provide database-backed implementations for stateless pod architectures. - * - * Uses a shared event bus to allow cloud servers to hook into lifecycle events. */ export class StagehandServer { private app: FastifyInstance; @@ -102,15 +90,13 @@ export class StagehandServer { 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.sessionStore = options.sessionStore ?? new InMemorySessionStore(); this.app = Fastify({ - logger: false, // Disable Fastify's built-in logger for cleaner output + logger: false, }); this.setupMiddleware(); @@ -124,18 +110,7 @@ export class StagehandServer { return this.sessionStore; } - /** - * 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"], @@ -150,7 +125,7 @@ export class StagehandServer { return { status: "ok" }; }); - // Start session - creates a new V3 instance + // Start session this.app.post("/v1/sessions/start", async (request, reply) => { return this.handleStartSession(request, reply); }); @@ -187,7 +162,7 @@ export class StagehandServer { }, ); - // Navigate endpoint - navigate to URL + // Navigate endpoint this.app.post<{ Params: { id: string } }>( "/v1/sessions/:id/navigate", async (request, reply) => { @@ -211,36 +186,10 @@ export class StagehandServer { request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const requestId = randomUUID(); - const startTime = Date.now(); - try { - // 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, - }); - const body = request.body as StartSessionParams; - // Build session params from body + headers - // Body contains most params, headers provide credentials and metadata const createParams: CreateSessionParams = { - // Core params from body modelName: body.modelName, verbose: body.verbose as 0 | 1 | 2, systemPrompt: body.systemPrompt, @@ -252,28 +201,14 @@ export class StagehandServer { browserbaseSessionCreateParams: body.browserbaseSessionCreateParams, debugDom: body.debugDom, actTimeoutMs: body.actTimeoutMs, - // Credentials from headers browserbaseApiKey: request.headers["x-bb-api-key"] as string | undefined, browserbaseProjectId: request.headers["x-bb-project-id"] as string | undefined, - // Metadata from headers clientLanguage: request.headers["x-language"] as string | undefined, sdkVersion: request.headers["x-sdk-version"] as string | undefined, }; - // Start session via the store (handles platform-specific logic) const result = await this.sessionStore.startSession(createParams); - // Emit request completed event - await this.emitAsync("StagehandRequestCompleted", { - type: "StagehandRequestCompleted", - timestamp: new Date(), - sessionId: result.sessionId, - requestId, - statusCode: 200, - durationMs: Date.now() - startTime, - }); - - // Match cloud API shape: { success: true, data: { sessionId, available } } reply.status(200).send({ success: true, data: { @@ -282,19 +217,9 @@ export class StagehandServer { }, }); } catch (error) { - await this.emitAsync("StagehandRequestCompleted", { - type: "StagehandRequestCompleted", - timestamp: new Date(), - requestId, - sessionId: "", - statusCode: 500, - durationMs: Date.now() - startTime, - }); - reply.status(500).send({ success: false, - message: - error instanceof Error ? error.message : "Failed to create session", + message: error instanceof Error ? error.message : "Failed to create session", }); } } @@ -307,63 +232,27 @@ export class StagehandServer { reply: StagehandHttpReply, ): Promise { const { id: sessionId } = request.params as { id: string }; - 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 (!(await this.sessionStore.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); - - // Build request context const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, }; await createStreamingResponse>({ sessionId, - requestId, - actionType: "act", sessionStore: this.sessionStore, requestContext: ctx, request, reply, - eventBus: this.eventBus, handler: async (handlerCtx, data) => { const stagehand = handlerCtx.stagehand as any; const { frameId } = data; - // Get the page const page = frameId ? stagehand.context.resolvePageByMainFrameId(frameId) : await stagehand.context.awaitActivePage(); @@ -372,7 +261,6 @@ export class StagehandServer { throw new Error("Page not found"); } - // Build options const safeOptions: ActOptions = { model: data.options?.model ? ({ @@ -385,7 +273,6 @@ export class StagehandServer { page, }; - // Execute act let result: ActResult; if (typeof data.input === "string") { result = await stagehand.act(data.input, safeOptions); @@ -396,26 +283,7 @@ export class StagehandServer { 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", @@ -434,55 +302,23 @@ export class StagehandServer { reply: StagehandHttpReply, ): Promise { const { id: sessionId } = request.params as { id: string }; - 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 (!(await this.sessionStore.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); - const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, }; await createStreamingResponse>({ sessionId, - requestId, - actionType: "extract", sessionStore: this.sessionStore, requestContext: ctx, request, reply, - eventBus: this.eventBus, handler: async (handlerCtx, data) => { const stagehand = handlerCtx.stagehand as any; const { frameId } = data; @@ -511,15 +347,10 @@ export class StagehandServer { if (data.instruction) { if (data.schema) { - // Convert JSON schema (sent by StagehandAPIClient) back to a Zod schema const zodSchema = jsonSchemaToZod( data.schema as unknown as JsonSchema, ) as StagehandZodSchema; - result = await stagehand.extract( - data.instruction, - zodSchema, - safeOptions, - ); + result = await stagehand.extract(data.instruction, zodSchema, safeOptions); } else { result = await stagehand.extract(data.instruction, safeOptions); } @@ -530,25 +361,7 @@ export class StagehandServer { 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", @@ -567,55 +380,23 @@ export class StagehandServer { reply: StagehandHttpReply, ): Promise { const { id: sessionId } = request.params as { id: string }; - 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 (!(await this.sessionStore.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); - const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, }; await createStreamingResponse>({ sessionId, - requestId, - actionType: "observe", sessionStore: this.sessionStore, requestContext: ctx, request, reply, - eventBus: this.eventBus, handler: async (handlerCtx, data) => { const stagehand = handlerCtx.stagehand as any; const { frameId } = data; @@ -652,25 +433,7 @@ export class StagehandServer { 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", @@ -689,55 +452,23 @@ export class StagehandServer { reply: StagehandHttpReply, ): Promise { const { id: sessionId } = request.params as { id: string }; - 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 (!(await this.sessionStore.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); - const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, }; await createStreamingResponse>({ sessionId, - requestId, - actionType: "agentExecute", sessionStore: this.sessionStore, requestContext: ctx, request, reply, - eventBus: this.eventBus, handler: async (handlerCtx, data) => { const stagehand = handlerCtx.stagehand as any; const { agentConfig, executeOptions, frameId } = data; @@ -755,32 +486,12 @@ export class StagehandServer { page, }; - const result: AgentResult = await stagehand - .agent(agentConfig) - .execute(fullExecuteOptions); + 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", @@ -799,36 +510,8 @@ export class StagehandServer { reply: StagehandHttpReply, ): Promise { const { id: sessionId } = request.params as { id: string }; - 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 (!(await this.sessionStore.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" }); } @@ -839,13 +522,10 @@ export class StagehandServer { await createStreamingResponse({ sessionId, - requestId, - actionType: "navigate", sessionStore: this.sessionStore, requestContext: ctx, request, reply, - eventBus: this.eventBus, handler: async (handlerCtx, data: any) => { const stagehand = handlerCtx.stagehand as any; const { url, options, frameId } = data; @@ -854,7 +534,6 @@ export class StagehandServer { throw new Error("url is required"); } - // Get the page const page = frameId ? stagehand.context.resolvePageByMainFrameId(frameId) : await stagehand.context.awaitActivePage(); @@ -863,31 +542,12 @@ export class StagehandServer { 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", @@ -904,59 +564,11 @@ export class StagehandServer { reply: StagehandHttpReply, ): Promise { const { id: sessionId } = request.params as { id: string }; - 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 { - // End session (handles platform cleanup + cache eviction) await this.sessionStore.endSession(sessionId); - - // Emit session ended event - await this.eventBus.emitAsync("StagehandSessionEnded", { - type: "StagehandSessionEnded", - timestamp: new Date(), - sessionId, - reason: "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", }); @@ -975,21 +587,6 @@ export class StagehandServer { 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); @@ -1001,21 +598,10 @@ export class StagehandServer { * 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; } - - // Cleanup session store await this.sessionStore.destroy(); } @@ -1028,14 +614,4 @@ export class StagehandServer { } return `http://${this.host}:${this.port}`; } - - /** - * Subscribe to server events - */ - on( - event: K, - listener: (data: StagehandServerEventMap[K]) => void | Promise, - ): void { - this.eventBus.on(event, listener); - } } diff --git a/packages/core/lib/v3/server/stream.ts b/packages/core/lib/v3/server/stream.ts index f6eb414b1..e6af55f6b 100644 --- a/packages/core/lib/v3/server/stream.ts +++ b/packages/core/lib/v3/server/stream.ts @@ -1,16 +1,6 @@ import { randomUUID } from "crypto"; import type { V3 } from "../v3"; import type { SessionStore, RequestContext } from "./SessionStore"; -import type { StagehandEventBus } from "../eventBus"; -import type { - StagehandActionStartedEvent, - StagehandActionCompletedEvent, - StagehandActionErroredEvent, - StagehandStreamStartedEvent, - StagehandStreamMessageSentEvent, - StagehandStreamEndedEvent, - StagehandActionProgressEvent, -} from "./events"; /** * Generic HTTP request interface for streaming. @@ -29,10 +19,13 @@ export interface StreamingHttpReply { status(code: number): StreamingHttpReply; send(payload: unknown): Promise | unknown; raw: { - writeHead(statusCode: number, headers: Record): void; + writeHead?(statusCode: number, headers: Record): void; write(chunk: string | Buffer): boolean; end(): void; + on?(event: string, handler: (...args: unknown[]) => void): unknown; }; + sent?: boolean; + hijack?(): void; } export interface StreamingHandlerResult { @@ -42,68 +35,40 @@ export interface StreamingHandlerResult { export interface StreamingHandlerContext { stagehand: V3; sessionId: string; - requestId: string; request: StreamingHttpRequest; - actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; - eventBus: StagehandEventBus; } export interface StreamingResponseOptions { sessionId: string; - requestId: string; - actionType: "act" | "extract" | "observe" | "agentExecute" | "navigate"; sessionStore: SessionStore; requestContext: RequestContext; request: StreamingHttpRequest; reply: StreamingHttpReply; - eventBus: StagehandEventBus; handler: (ctx: StreamingHandlerContext, data: T) => Promise; } /** * Sends an SSE (Server-Sent Events) message to the client */ -async function sendSSE( - reply: StreamingHttpReply, - data: object, - eventBus: StagehandEventBus, - sessionId: string, - requestId: string, -): Promise { +function sendSSE(reply: StreamingHttpReply, data: object): void { 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, sessionStore, requestContext, 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"; @@ -125,29 +90,14 @@ export async function createStreamingResponse({ "Access-Control-Allow-Credentials": "true", }); - // Emit stream started event - await eventBus.emitAsync("StagehandStreamStarted", { - type: "StagehandStreamStarted", - timestamp: new Date(), - sessionId, - requestId, + sendSSE(reply, { + type: "system", + data: { status: "starting" }, }); - - 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 { // Build request context with streaming logger if needed @@ -155,29 +105,12 @@ export async function createStreamingResponse({ ...requestContext, logger: shouldStream ? async (message) => { - await sendSSE( - reply, - { - type: "log", - data: { - status: "running", - message, - }, + 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, @@ -187,82 +120,22 @@ export async function createStreamingResponse({ const stagehand = await sessionStore.getOrCreateStagehand(sessionId, ctxWithLogger); if (shouldStream) { - await sendSSE( - reply, - { - type: "system", - data: { status: "connected" }, - }, - eventBus, - sessionId, - requestId, - ); + sendSSE(reply, { + type: "system", + data: { status: "connected" }, + }); } - // 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 @@ -270,28 +143,13 @@ export async function createStreamingResponse({ const errorMessage = handlerError.message || "An unexpected error occurred"; if (shouldStream) { - await sendSSE( - reply, - { - type: "system", - data: { - status: "error", - error: errorMessage, - }, + 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({ @@ -303,28 +161,13 @@ export async function createStreamingResponse({ // Handle success case if (shouldStream) { - await sendSSE( - reply, - { - type: "system", - data: { - status: "finished", - result: result?.result, - }, + 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({ From 40312bf14576d5bc77cfe21231a25e09f6b1f355 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:23:42 -0800 Subject: [PATCH 13/30] fix type errors in library p2p server --- packages/core/lib/v3/server/SessionStore.ts | 2 +- packages/core/lib/v3/server/index.ts | 35 +++++++++++++-------- packages/core/lib/v3/server/stream.ts | 2 +- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/core/lib/v3/server/SessionStore.ts b/packages/core/lib/v3/server/SessionStore.ts index 6ce4717bb..868913aff 100644 --- a/packages/core/lib/v3/server/SessionStore.ts +++ b/packages/core/lib/v3/server/SessionStore.ts @@ -1,4 +1,4 @@ -import type { V3Options, LogLine } from "../types/public"; +import type { LogLine } from "../types/public"; import type { V3 } from "../v3"; /** diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 3b7246d63..32aa27abb 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -234,11 +234,12 @@ export class StagehandServer { const { id: sessionId } = request.params as { id: string }; if (!(await this.sessionStore.hasSession(sessionId))) { - return reply.status(404).send({ error: "Session not found" }); + reply.status(404).send({ error: "Session not found" }); + return; } try { - const data = actSchemaV3.parse(request.body); + actSchemaV3.parse(request.body); // Validate request body const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, }; @@ -285,10 +286,11 @@ export class StagehandServer { }); } catch (error) { if (error instanceof z.ZodError) { - return reply.status(400).send({ + reply.status(400).send({ error: "Invalid request body", details: error.issues, }); + return; } throw error; } @@ -304,11 +306,12 @@ export class StagehandServer { const { id: sessionId } = request.params as { id: string }; if (!(await this.sessionStore.hasSession(sessionId))) { - return reply.status(404).send({ error: "Session not found" }); + reply.status(404).send({ error: "Session not found" }); + return; } try { - const data = extractSchemaV3.parse(request.body); + extractSchemaV3.parse(request.body); // Validate request body const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, }; @@ -363,10 +366,11 @@ export class StagehandServer { }); } catch (error) { if (error instanceof z.ZodError) { - return reply.status(400).send({ + reply.status(400).send({ error: "Invalid request body", details: error.issues, }); + return; } throw error; } @@ -382,11 +386,12 @@ export class StagehandServer { const { id: sessionId } = request.params as { id: string }; if (!(await this.sessionStore.hasSession(sessionId))) { - return reply.status(404).send({ error: "Session not found" }); + reply.status(404).send({ error: "Session not found" }); + return; } try { - const data = observeSchemaV3.parse(request.body); + observeSchemaV3.parse(request.body); // Validate request body const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, }; @@ -435,10 +440,11 @@ export class StagehandServer { }); } catch (error) { if (error instanceof z.ZodError) { - return reply.status(400).send({ + reply.status(400).send({ error: "Invalid request body", details: error.issues, }); + return; } throw error; } @@ -454,11 +460,12 @@ export class StagehandServer { const { id: sessionId } = request.params as { id: string }; if (!(await this.sessionStore.hasSession(sessionId))) { - return reply.status(404).send({ error: "Session not found" }); + reply.status(404).send({ error: "Session not found" }); + return; } try { - const data = agentExecuteSchemaV3.parse(request.body); + agentExecuteSchemaV3.parse(request.body); // Validate request body const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, }; @@ -493,10 +500,11 @@ export class StagehandServer { }); } catch (error) { if (error instanceof z.ZodError) { - return reply.status(400).send({ + reply.status(400).send({ error: "Invalid request body", details: error.issues, }); + return; } throw error; } @@ -512,7 +520,8 @@ export class StagehandServer { const { id: sessionId } = request.params as { id: string }; if (!(await this.sessionStore.hasSession(sessionId))) { - return reply.status(404).send({ error: "Session not found" }); + reply.status(404).send({ error: "Session not found" }); + return; } try { diff --git a/packages/core/lib/v3/server/stream.ts b/packages/core/lib/v3/server/stream.ts index e6af55f6b..16822ee2e 100644 --- a/packages/core/lib/v3/server/stream.ts +++ b/packages/core/lib/v3/server/stream.ts @@ -78,7 +78,7 @@ export async function createStreamingResponse({ // Set up SSE response if streaming if (shouldStream) { - reply.raw.writeHead(200, { + reply.raw.writeHead?.(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", From eb76ea319c360a7c437f925c4919067330059388 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:24:24 -0800 Subject: [PATCH 14/30] add missing type export --- packages/core/lib/v3/server/SessionStore.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/lib/v3/server/SessionStore.ts b/packages/core/lib/v3/server/SessionStore.ts index 868913aff..ae6bbed7e 100644 --- a/packages/core/lib/v3/server/SessionStore.ts +++ b/packages/core/lib/v3/server/SessionStore.ts @@ -1,5 +1,8 @@ import type { LogLine } from "../types/public"; import type { V3 } from "../v3"; +import type { StartSessionResult } from "./schemas"; + +export type { StartSessionResult }; /** * Parameters for creating a new session. @@ -67,8 +70,6 @@ export interface SessionCacheConfig { ttlMs?: number; } -// Re-export StartSessionResult from schemas (defined as Zod schema) -export type { StartSessionResult } from "./schemas"; /** * SessionStore interface for managing session lifecycle and V3 instances. From e587d8e04e2dd2c9e5af1f75aa1fd257ad335aa5 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:36:10 -0800 Subject: [PATCH 15/30] add fastify-type-provider-zod to validate request/response shapes before routes --- packages/core/examples/p2p-client-example.ts | 122 ----- packages/core/examples/p2p-server-example.ts | 103 ---- packages/core/lib/v3/server/index.ts | 541 ++++++++++--------- packages/core/package.json | 1 + pnpm-lock.yaml | 66 ++- 5 files changed, 332 insertions(+), 501 deletions(-) delete mode 100644 packages/core/examples/p2p-client-example.ts delete mode 100644 packages/core/examples/p2p-server-example.ts diff --git a/packages/core/examples/p2p-client-example.ts b/packages/core/examples/p2p-client-example.ts deleted file mode 100644 index 0f4fc4b5a..000000000 --- a/packages/core/examples/p2p-client-example.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * 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}...`); - - // When STAGEHAND_API_URL is set to the P2P server URL - // (e.g. "http://localhost:3000/v1"), Stagehand will use the HTTP API - // instead of launching a local browser. - if (!process.env.STAGEHAND_API_URL) { - process.env.STAGEHAND_API_URL = `${SERVER_URL}/v1`; - } - - const stagehand = new Stagehand({ - env: "BROWSERBASE", - verbose: 1, - }); - - await stagehand.init(); - 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 deleted file mode 100644 index 5a29ee7b1..000000000 --- a/packages/core/examples/p2p-server-example.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * 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(" // In your client process:"); - console.log(` process.env.STAGEHAND_API_URL = '${server.getUrl()}/v1';`); - console.log( - " const stagehand = new Stagehand({ env: 'LOCAL', verbose: 1 });", - ); - console.log(" await stagehand.init();"); - 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/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 32aa27abb..34b0b07de 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -1,6 +1,10 @@ import Fastify, { FastifyInstance } from "fastify"; import cors from "@fastify/cors"; -import { z } from "zod"; +import { + serializerCompiler, + validatorCompiler, + type ZodTypeProvider, +} from "fastify-type-provider-zod"; import type { ActOptions, ActResult, @@ -21,8 +25,17 @@ import { extractSchemaV3, observeSchemaV3, agentExecuteSchemaV3, + navigateSchemaV3, + startSessionRequestSchema, + sessionIdParamsSchema, + type StartSessionRequest, + type ActRequest, + type ExtractRequest, + type ObserveRequest, + type AgentExecuteRequest, + type NavigateRequest, + type SessionIdParams, } from "./schemas"; -import type { StartSessionParams } from "../types/private/api"; // ============================================================================= // Generic HTTP interfaces for cross-version Fastify compatibility @@ -99,6 +112,10 @@ export class StagehandServer { logger: false, }); + // Set up Zod type provider for automatic request/response validation + this.app.setValidatorCompiler(validatorCompiler); + this.app.setSerializerCompiler(serializerCompiler); + this.setupMiddleware(); this.setupRoutes(); } @@ -120,59 +137,104 @@ export class StagehandServer { } private setupRoutes(): void { + const app = this.app.withTypeProvider(); + // Health check - this.app.get("/health", async () => { + app.get("/health", async () => { return { status: "ok" }; }); // Start session - this.app.post("/v1/sessions/start", async (request, reply) => { - return this.handleStartSession(request, reply); - }); + app.post( + "/v1/sessions/start", + { + schema: { + body: startSessionRequestSchema, + }, + }, + async (request, reply) => { + return this.handleStartSession(request, reply); + }, + ); // Act endpoint - this.app.post<{ Params: { id: string } }>( + app.post( "/v1/sessions/:id/act", + { + schema: { + params: sessionIdParamsSchema, + body: actSchemaV3, + }, + }, async (request, reply) => { return this.handleAct(request, reply); }, ); // Extract endpoint - this.app.post<{ Params: { id: string } }>( + app.post( "/v1/sessions/:id/extract", + { + schema: { + params: sessionIdParamsSchema, + body: extractSchemaV3, + }, + }, async (request, reply) => { return this.handleExtract(request, reply); }, ); // Observe endpoint - this.app.post<{ Params: { id: string } }>( + app.post( "/v1/sessions/:id/observe", + { + schema: { + params: sessionIdParamsSchema, + body: observeSchemaV3, + }, + }, async (request, reply) => { return this.handleObserve(request, reply); }, ); // Agent execute endpoint - this.app.post<{ Params: { id: string } }>( + app.post( "/v1/sessions/:id/agentExecute", + { + schema: { + params: sessionIdParamsSchema, + body: agentExecuteSchemaV3, + }, + }, async (request, reply) => { return this.handleAgentExecute(request, reply); }, ); // Navigate endpoint - this.app.post<{ Params: { id: string } }>( + app.post( "/v1/sessions/:id/navigate", + { + schema: { + params: sessionIdParamsSchema, + body: navigateSchemaV3, + }, + }, async (request, reply) => { return this.handleNavigate(request, reply); }, ); // End session - this.app.post<{ Params: { id: string } }>( + app.post( "/v1/sessions/:id/end", + { + schema: { + params: sessionIdParamsSchema, + }, + }, async (request, reply) => { return this.handleEndSession(request, reply); }, @@ -181,17 +243,18 @@ export class StagehandServer { /** * Handle /sessions/start - Create new session + * Body is pre-validated by Fastify using startSessionRequestSchema */ async handleStartSession( request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { try { - const body = request.body as StartSessionParams; + const body = request.body as StartSessionRequest; const createParams: CreateSessionParams = { modelName: body.modelName, - verbose: body.verbose as 0 | 1 | 2, + verbose: body.verbose, systemPrompt: body.systemPrompt, selfHeal: body.selfHeal, domSettleTimeoutMs: body.domSettleTimeoutMs, @@ -226,353 +289,299 @@ export class StagehandServer { /** * Handle /sessions/:id/act - Execute act command + * Body is pre-validated by Fastify using actSchemaV3 */ async handleAct( request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as { id: string }; + const { id: sessionId } = request.params as SessionIdParams; if (!(await this.sessionStore.hasSession(sessionId))) { reply.status(404).send({ error: "Session not found" }); return; } - try { - actSchemaV3.parse(request.body); // Validate request body - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; - - await createStreamingResponse>({ - sessionId, - sessionStore: this.sessionStore, - requestContext: ctx, - request, - reply, - handler: async (handlerCtx, data) => { - const stagehand = handlerCtx.stagehand as any; - const { frameId } = data; - - const page = frameId - ? stagehand.context.resolvePageByMainFrameId(frameId) - : await stagehand.context.awaitActivePage(); - - if (!page) { - throw new Error("Page not found"); - } - - const safeOptions: ActOptions = { - model: data.options?.model - ? ({ - ...data.options.model, - modelName: data.options.model.model ?? "gpt-4o", - } as ModelConfiguration) - : undefined, - variables: data.options?.variables, - timeout: data.options?.timeout, - page, - }; - - 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 }; - }, - }); - } catch (error) { - if (error instanceof z.ZodError) { - reply.status(400).send({ - error: "Invalid request body", - details: error.issues, - }); - return; - } - throw error; - } + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; + + await createStreamingResponse({ + sessionId, + sessionStore: this.sessionStore, + requestContext: ctx, + request, + reply, + handler: async (handlerCtx, data) => { + const stagehand = handlerCtx.stagehand as any; + const { frameId } = data; + + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); + + if (!page) { + throw new Error("Page not found"); + } + + const safeOptions: ActOptions = { + model: data.options?.model + ? ({ + ...data.options.model, + modelName: data.options.model.model ?? "gpt-4o", + } as ModelConfiguration) + : undefined, + variables: data.options?.variables, + timeout: data.options?.timeout, + page, + }; + + 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 }; + }, + }); } /** * Handle /sessions/:id/extract - Execute extract command + * Body is pre-validated by Fastify using extractSchemaV3 */ async handleExtract( request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as { id: string }; + const { id: sessionId } = request.params as SessionIdParams; if (!(await this.sessionStore.hasSession(sessionId))) { reply.status(404).send({ error: "Session not found" }); return; } - try { - extractSchemaV3.parse(request.body); // Validate request body - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; - - await createStreamingResponse>({ - sessionId, - sessionStore: this.sessionStore, - requestContext: ctx, - request, - reply, - handler: async (handlerCtx, data) => { - const stagehand = handlerCtx.stagehand as any; - 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", - } as ModelConfiguration) - : undefined, - timeout: data.options?.timeout, - selector: data.options?.selector, - page, - }; - - let result: ExtractResult; - - if (data.instruction) { - if (data.schema) { - const zodSchema = jsonSchemaToZod( - data.schema as unknown as JsonSchema, - ) as StagehandZodSchema; - result = await stagehand.extract(data.instruction, zodSchema, safeOptions); - } else { - result = await stagehand.extract(data.instruction, safeOptions); - } + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; + + await createStreamingResponse({ + sessionId, + sessionStore: this.sessionStore, + requestContext: ctx, + request, + reply, + handler: async (handlerCtx, data) => { + const stagehand = handlerCtx.stagehand as any; + 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", + } as ModelConfiguration) + : undefined, + timeout: data.options?.timeout, + selector: data.options?.selector, + page, + }; + + let result: ExtractResult; + + if (data.instruction) { + if (data.schema) { + const zodSchema = jsonSchemaToZod( + data.schema as unknown as JsonSchema, + ) as StagehandZodSchema; + result = await stagehand.extract(data.instruction, zodSchema, safeOptions); } else { - result = await stagehand.extract(safeOptions); + result = await stagehand.extract(data.instruction, safeOptions); } + } else { + result = await stagehand.extract(safeOptions); + } - return { result }; - }, - }); - } catch (error) { - if (error instanceof z.ZodError) { - reply.status(400).send({ - error: "Invalid request body", - details: error.issues, - }); - return; - } - throw error; - } + return { result }; + }, + }); } /** * Handle /sessions/:id/observe - Execute observe command + * Body is pre-validated by Fastify using observeSchemaV3 */ async handleObserve( request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as { id: string }; + const { id: sessionId } = request.params as SessionIdParams; if (!(await this.sessionStore.hasSession(sessionId))) { reply.status(404).send({ error: "Session not found" }); return; } - try { - observeSchemaV3.parse(request.body); // Validate request body - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; + + await createStreamingResponse({ + sessionId, + sessionStore: this.sessionStore, + requestContext: ctx, + request, + reply, + handler: async (handlerCtx, data) => { + const stagehand = handlerCtx.stagehand as any; + 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, + } as ModelConfiguration) + : undefined, + timeout: data.options?.timeout, + selector: data.options?.selector, + page, + }; - await createStreamingResponse>({ - sessionId, - sessionStore: this.sessionStore, - requestContext: ctx, - request, - reply, - handler: async (handlerCtx, data) => { - const stagehand = handlerCtx.stagehand as any; - const { frameId } = data; - - const page = frameId - ? stagehand.context.resolvePageByMainFrameId(frameId) - : await stagehand.context.awaitActivePage(); - - if (!page) { - throw new Error("Page not found"); - } + let result: Action[]; - const safeOptions: ObserveOptions = { - model: - data.options?.model && typeof data.options.model.model === "string" - ? ({ - ...data.options.model, - modelName: data.options.model.model, - } as ModelConfiguration) - : 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); - } + if (data.instruction) { + result = await stagehand.observe(data.instruction, safeOptions); + } else { + result = await stagehand.observe(safeOptions); + } - return { result }; - }, - }); - } catch (error) { - if (error instanceof z.ZodError) { - reply.status(400).send({ - error: "Invalid request body", - details: error.issues, - }); - return; - } - throw error; - } + return { result }; + }, + }); } /** * Handle /sessions/:id/agentExecute - Execute agent command + * Body is pre-validated by Fastify using agentExecuteSchemaV3 */ async handleAgentExecute( request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as { id: string }; + const { id: sessionId } = request.params as SessionIdParams; if (!(await this.sessionStore.hasSession(sessionId))) { reply.status(404).send({ error: "Session not found" }); return; } - try { - agentExecuteSchemaV3.parse(request.body); // Validate request body - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; - await createStreamingResponse>({ - sessionId, - sessionStore: this.sessionStore, - requestContext: ctx, - request, - reply, - handler: async (handlerCtx, data) => { - const stagehand = handlerCtx.stagehand as any; - const { agentConfig, executeOptions, frameId } = data; - - const page = frameId - ? stagehand.context.resolvePageByMainFrameId(frameId) - : await stagehand.context.awaitActivePage(); - - if (!page) { - throw new Error("Page not found"); - } + await createStreamingResponse({ + sessionId, + sessionStore: this.sessionStore, + requestContext: ctx, + request, + reply, + handler: async (handlerCtx, data) => { + const stagehand = handlerCtx.stagehand as any; + const { agentConfig, executeOptions, frameId } = data; - const fullExecuteOptions = { - ...executeOptions, - page, - }; + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); - const result: AgentResult = await stagehand.agent(agentConfig).execute(fullExecuteOptions); + if (!page) { + throw new Error("Page not found"); + } - return { result }; - }, - }); - } catch (error) { - if (error instanceof z.ZodError) { - reply.status(400).send({ - error: "Invalid request body", - details: error.issues, - }); - return; - } - throw error; - } + const fullExecuteOptions = { + ...executeOptions, + page, + }; + + const result: AgentResult = await stagehand.agent(agentConfig).execute(fullExecuteOptions); + + return { result }; + }, + }); } /** * Handle /sessions/:id/navigate - Navigate to URL + * Body is pre-validated by Fastify using navigateSchemaV3 */ async handleNavigate( request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as { id: string }; + const { id: sessionId } = request.params as SessionIdParams; if (!(await this.sessionStore.hasSession(sessionId))) { reply.status(404).send({ error: "Session not found" }); return; } - try { - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; + const ctx: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, + }; - await createStreamingResponse({ - sessionId, - sessionStore: this.sessionStore, - requestContext: ctx, - request, - reply, - handler: async (handlerCtx, data: any) => { - const stagehand = handlerCtx.stagehand as any; - const { url, options, frameId } = data; - - if (!url) { - throw new Error("url is required"); - } + await createStreamingResponse({ + sessionId, + sessionStore: this.sessionStore, + requestContext: ctx, + request, + reply, + handler: async (handlerCtx, data) => { + const stagehand = handlerCtx.stagehand as any; + const { url, options, frameId } = data; - const page = frameId - ? stagehand.context.resolvePageByMainFrameId(frameId) - : await stagehand.context.awaitActivePage(); + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); - if (!page) { - throw new Error("Page not found"); - } + if (!page) { + throw new Error("Page not found"); + } - const response = await page.goto(url, options); + const response = await page.goto(url, options); - return { result: response }; - }, - }); - } catch (error) { - if (!reply.sent) { - reply.status(500).send({ - error: error instanceof Error ? error.message : "Failed to navigate", - }); - } - } + return { result: response }; + }, + }); } /** * Handle /sessions/:id/end - End session and cleanup + * Params are pre-validated by Fastify using sessionIdParamsSchema */ async handleEndSession( request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as { id: string }; + const { id: sessionId } = request.params as SessionIdParams; try { await this.sessionStore.endSession(sessionId); diff --git a/packages/core/package.json b/packages/core/package.json index 988186de5..e2ae661c3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -67,6 +67,7 @@ "ai": "^5.0.0", "devtools-protocol": "^0.0.1464554", "fastify": "^5.2.4", + "fastify-type-provider-zod": "^6.1.0", "fetch-cookie": "^3.1.0", "openai": "^4.87.1", "pino": "^9.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4b96764c..cc1f26cea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 9.25.1 '@langchain/community': specifier: ^1.0.0 - version: 1.0.0(@browserbasehq/sdk@2.5.0)(@browserbasehq/stagehand@3.0.3(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@16.5.0)(zod@3.25.67))(@ibm-cloud/watsonx-ai@1.7.0)(@langchain/core@0.3.50(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67)))(@opentelemetry/api@1.9.0)(cheerio@1.0.0)(google-auth-library@9.15.1)(ibm-cloud-sdk-core@5.4.3)(ignore@5.3.2)(jsonwebtoken@9.0.2)(lodash@4.17.21)(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67))(playwright@1.54.2)(puppeteer@22.15.0(bufferutil@4.0.9)(typescript@5.8.3))(ws@8.18.3(bufferutil@4.0.9)) + version: 1.0.0(@browserbasehq/sdk@2.5.0)(@browserbasehq/stagehand@3.0.5(deepmerge@4.3.1)(dotenv@16.5.0)(zod@3.25.67))(@ibm-cloud/watsonx-ai@1.7.0)(@langchain/core@0.3.50(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67)))(@opentelemetry/api@1.9.0)(cheerio@1.0.0)(google-auth-library@9.15.1)(ibm-cloud-sdk-core@5.4.3)(ignore@5.3.2)(jsonwebtoken@9.0.2)(lodash@4.17.21)(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67))(playwright@1.54.2)(puppeteer@22.15.0(bufferutil@4.0.9)(typescript@5.8.3))(ws@8.18.3(bufferutil@4.0.9)) '@langchain/core': specifier: ^0.3.40 version: 0.3.50(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67)) @@ -152,6 +152,9 @@ importers: fastify: specifier: ^5.2.4 version: 5.6.2 + fastify-type-provider-zod: + specifier: ^6.1.0 + version: 6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.1.8) fetch-cookie: specifier: ^3.1.0 version: 3.1.0 @@ -431,12 +434,12 @@ packages: '@browserbasehq/sdk@2.5.0': resolution: {integrity: sha512-bcnbYZvm5Ht1nrHUfWDK4crspiTy1ESJYMApsMiOTUnlKOan0ocRD6m7hZH34iSC2c2XWsoryR80cwsYgCBWzQ==} - '@browserbasehq/stagehand@3.0.3': - resolution: {integrity: sha512-O/9VgmOmIX4ZYuu2hgQ+7BmK8wkSgPX/kLzGQ/SJLCNYRW9yuU6/b4NRdFU5uJ7OlCKdEOcV1u4Cc4PhY67S0w==} + '@browserbasehq/stagehand@3.0.5': + resolution: {integrity: sha512-89QPlRKpfq8kJd8oGgodo5I39xDjEPwVIEvdjaICcU33X4yAtkvoR4lM2tEwGiiDu/BPQznB27JFgoGlMjUsTA==} peerDependencies: deepmerge: ^4.3.1 dotenv: ^16.4.5 - zod: 3.25.67 + zod: 3.25.76 || 4.1.8 '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} @@ -1008,6 +1011,9 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/swagger@9.6.1': + resolution: {integrity: sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA==} + '@google/genai@1.24.0': resolution: {integrity: sha512-e3jZF9Dx3dDaDCzygdMuYByHI2xJZ0PaD3r2fRgHZe2IOwBnmJ/Tu5Lt/nefTCxqr1ZnbcbQK9T13d8U/9UMWg==} engines: {node: '>=20.0.0'} @@ -1774,6 +1780,7 @@ packages: '@mintlify/cli@4.0.682': resolution: {integrity: sha512-91XL+qCw9hm2KpMgKsNASIfUHYLhYwSmeoMRkE6p5Iy7P5dPAxJd+PUFPXdh4EGhMNALGRLHzm9rUoNvthM89w==} engines: {node: '>=18.0.0'} + deprecated: This version is deprecated. Please upgrade to version 4.0.423 or later. hasBin: true '@mintlify/common@1.0.496': @@ -3540,6 +3547,14 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-type-provider-zod@6.1.0: + resolution: {integrity: sha512-Sl19VZFSX4W/+AFl3hkL5YgWk3eDXZ4XYOdrq94HunK+o7GQBCAqgk7+3gPXoWkF0bNxOiIgfnFGJJ3i9a2BtQ==} + peerDependencies: + '@fastify/swagger': '>=9.5.1' + fastify: ^5.5.0 + openapi-types: ^12.1.3 + zod: '>=4.1.5' + fastify@5.6.2: resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} @@ -4276,6 +4291,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -4733,6 +4752,7 @@ packages: mintlify@4.2.78: resolution: {integrity: sha512-g3naXSI7RsmxUNJ87mKzRefKaMdqbAhxfaPaMkApwmeDB0TROwwUO0CS6ZDsbV5Qq3Sm5kH4mEDieEpAE6JG8A==} engines: {node: '>=18.0.0'} + deprecated: This version is deprecated. Please upgrade to version 4.0.423 or later. hasBin: true mitt@3.0.1: @@ -6842,7 +6862,7 @@ snapshots: transitivePeerDependencies: - encoding - '@browserbasehq/stagehand@3.0.3(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@16.5.0)(zod@3.25.67)': + '@browserbasehq/stagehand@3.0.5(deepmerge@4.3.1)(dotenv@16.5.0)(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 '@anthropic-ai/sdk': 0.39.0 @@ -6875,6 +6895,7 @@ snapshots: '@ai-sdk/togetherai': 1.0.23(zod@3.25.67) '@ai-sdk/xai': 2.0.26(zod@3.25.67) '@langchain/core': 0.3.50(openai@4.96.2(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67)) + bufferutil: 4.0.9 chrome-launcher: 1.2.0 ollama-ai-provider-v2: 1.5.0(zod@3.25.67) patchright-core: 1.55.2 @@ -6882,7 +6903,6 @@ snapshots: puppeteer-core: 22.15.0(bufferutil@4.0.9) transitivePeerDependencies: - bare-buffer - - bufferutil - encoding - supports-color - utf-8-validate @@ -7345,6 +7365,16 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.2.0 + '@fastify/swagger@9.6.1': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.7.1 + transitivePeerDependencies: + - supports-color + '@google/genai@1.24.0(@modelcontextprotocol/sdk@1.17.2)(bufferutil@4.0.9)': dependencies: google-auth-library: 9.15.1 @@ -7641,9 +7671,9 @@ snapshots: - openai - ws - '@langchain/community@1.0.0(@browserbasehq/sdk@2.5.0)(@browserbasehq/stagehand@3.0.3(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@16.5.0)(zod@3.25.67))(@ibm-cloud/watsonx-ai@1.7.0)(@langchain/core@0.3.50(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67)))(@opentelemetry/api@1.9.0)(cheerio@1.0.0)(google-auth-library@9.15.1)(ibm-cloud-sdk-core@5.4.3)(ignore@5.3.2)(jsonwebtoken@9.0.2)(lodash@4.17.21)(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67))(playwright@1.54.2)(puppeteer@22.15.0(bufferutil@4.0.9)(typescript@5.8.3))(ws@8.18.3(bufferutil@4.0.9))': + '@langchain/community@1.0.0(@browserbasehq/sdk@2.5.0)(@browserbasehq/stagehand@3.0.5(deepmerge@4.3.1)(dotenv@16.5.0)(zod@3.25.67))(@ibm-cloud/watsonx-ai@1.7.0)(@langchain/core@0.3.50(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67)))(@opentelemetry/api@1.9.0)(cheerio@1.0.0)(google-auth-library@9.15.1)(ibm-cloud-sdk-core@5.4.3)(ignore@5.3.2)(jsonwebtoken@9.0.2)(lodash@4.17.21)(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67))(playwright@1.54.2)(puppeteer@22.15.0(bufferutil@4.0.9)(typescript@5.8.3))(ws@8.18.3(bufferutil@4.0.9))': dependencies: - '@browserbasehq/stagehand': 3.0.3(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@16.5.0)(zod@3.25.67) + '@browserbasehq/stagehand': 3.0.5(deepmerge@4.3.1)(dotenv@16.5.0)(zod@3.25.67) '@ibm-cloud/watsonx-ai': 1.7.0 '@langchain/classic': 1.0.0(@langchain/core@0.3.50(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67)))(@opentelemetry/api@1.9.0)(cheerio@1.0.0)(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67))(ws@8.18.3(bufferutil@4.0.9)) '@langchain/core': 0.3.50(openai@6.7.0(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.67)) @@ -10103,6 +10133,14 @@ snapshots: fastify-plugin@5.1.0: {} + fastify-type-provider-zod@6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.1.8): + dependencies: + '@fastify/error': 4.2.0 + '@fastify/swagger': 9.6.1 + fastify: 5.6.2 + openapi-types: 12.1.3 + zod: 4.1.8 + fastify@5.6.2: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -10725,7 +10763,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.13.0(debug@4.4.3)) + retry-axios: 2.6.0(axios@1.13.0) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -11047,6 +11085,14 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.0.6 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -12782,7 +12828,7 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 - retry-axios@2.6.0(axios@1.13.0(debug@4.4.3)): + retry-axios@2.6.0(axios@1.13.0): dependencies: axios: 1.13.0(debug@4.4.3) From 5263dce2f7da56ca2d6479454ce2c28c65699474 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:43:07 -0800 Subject: [PATCH 16/30] better naming --- packages/core/lib/v3/server/SessionStore.ts | 6 +- packages/core/lib/v3/server/index.ts | 56 +++--- packages/core/lib/v3/server/schemas.ts | 206 +++++++++++--------- 3 files changed, 146 insertions(+), 122 deletions(-) diff --git a/packages/core/lib/v3/server/SessionStore.ts b/packages/core/lib/v3/server/SessionStore.ts index ae6bbed7e..fdf45b54e 100644 --- a/packages/core/lib/v3/server/SessionStore.ts +++ b/packages/core/lib/v3/server/SessionStore.ts @@ -1,8 +1,8 @@ import type { LogLine } from "../types/public"; import type { V3 } from "../v3"; -import type { StartSessionResult } from "./schemas"; +import type { SessionStartResult } from "./schemas"; -export type { StartSessionResult }; +export type { SessionStartResult }; /** * Parameters for creating a new session. @@ -97,7 +97,7 @@ export interface SessionStore { * @param params - Session configuration * @returns Session ID and availability status */ - startSession(params: CreateSessionParams): Promise; + startSession(params: CreateSessionParams): Promise; /** * End a session and cleanup all resources. diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 34b0b07de..6250b2904 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -21,14 +21,14 @@ import type { SessionStore, RequestContext, CreateSessionParams } from "./Sessio import { InMemorySessionStore } from "./InMemorySessionStore"; import { createStreamingResponse } from "./stream"; import { - actSchemaV3, - extractSchemaV3, - observeSchemaV3, - agentExecuteSchemaV3, - navigateSchemaV3, - startSessionRequestSchema, - sessionIdParamsSchema, - type StartSessionRequest, + ActRequestSchema, + ExtractRequestSchema, + ObserveRequestSchema, + AgentExecuteRequestSchema, + NavigateRequestSchema, + SessionStartRequestSchema, + SessionIdParamsSchema, + type SessionStartRequest, type ActRequest, type ExtractRequest, type ObserveRequest, @@ -149,7 +149,7 @@ export class StagehandServer { "/v1/sessions/start", { schema: { - body: startSessionRequestSchema, + body: SessionStartRequestSchema, }, }, async (request, reply) => { @@ -162,8 +162,8 @@ export class StagehandServer { "/v1/sessions/:id/act", { schema: { - params: sessionIdParamsSchema, - body: actSchemaV3, + params: SessionIdParamsSchema, + body: ActRequestSchema, }, }, async (request, reply) => { @@ -176,8 +176,8 @@ export class StagehandServer { "/v1/sessions/:id/extract", { schema: { - params: sessionIdParamsSchema, - body: extractSchemaV3, + params: SessionIdParamsSchema, + body: ExtractRequestSchema, }, }, async (request, reply) => { @@ -190,8 +190,8 @@ export class StagehandServer { "/v1/sessions/:id/observe", { schema: { - params: sessionIdParamsSchema, - body: observeSchemaV3, + params: SessionIdParamsSchema, + body: ObserveRequestSchema, }, }, async (request, reply) => { @@ -204,8 +204,8 @@ export class StagehandServer { "/v1/sessions/:id/agentExecute", { schema: { - params: sessionIdParamsSchema, - body: agentExecuteSchemaV3, + params: SessionIdParamsSchema, + body: AgentExecuteRequestSchema, }, }, async (request, reply) => { @@ -218,8 +218,8 @@ export class StagehandServer { "/v1/sessions/:id/navigate", { schema: { - params: sessionIdParamsSchema, - body: navigateSchemaV3, + params: SessionIdParamsSchema, + body: NavigateRequestSchema, }, }, async (request, reply) => { @@ -232,7 +232,7 @@ export class StagehandServer { "/v1/sessions/:id/end", { schema: { - params: sessionIdParamsSchema, + params: SessionIdParamsSchema, }, }, async (request, reply) => { @@ -243,14 +243,14 @@ export class StagehandServer { /** * Handle /sessions/start - Create new session - * Body is pre-validated by Fastify using startSessionRequestSchema + * Body is pre-validated by Fastify using SessionStartRequestSchema */ async handleStartSession( request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { try { - const body = request.body as StartSessionRequest; + const body = request.body as SessionStartRequest; const createParams: CreateSessionParams = { modelName: body.modelName, @@ -289,7 +289,7 @@ export class StagehandServer { /** * Handle /sessions/:id/act - Execute act command - * Body is pre-validated by Fastify using actSchemaV3 + * Body is pre-validated by Fastify using ActRequestSchema */ async handleAct( request: StagehandHttpRequest, @@ -350,7 +350,7 @@ export class StagehandServer { /** * Handle /sessions/:id/extract - Execute extract command - * Body is pre-validated by Fastify using extractSchemaV3 + * Body is pre-validated by Fastify using ExtractRequestSchema */ async handleExtract( request: StagehandHttpRequest, @@ -419,7 +419,7 @@ export class StagehandServer { /** * Handle /sessions/:id/observe - Execute observe command - * Body is pre-validated by Fastify using observeSchemaV3 + * Body is pre-validated by Fastify using ObserveRequestSchema */ async handleObserve( request: StagehandHttpRequest, @@ -482,7 +482,7 @@ export class StagehandServer { /** * Handle /sessions/:id/agentExecute - Execute agent command - * Body is pre-validated by Fastify using agentExecuteSchemaV3 + * Body is pre-validated by Fastify using AgentExecuteRequestSchema */ async handleAgentExecute( request: StagehandHttpRequest, @@ -531,7 +531,7 @@ export class StagehandServer { /** * Handle /sessions/:id/navigate - Navigate to URL - * Body is pre-validated by Fastify using navigateSchemaV3 + * Body is pre-validated by Fastify using NavigateRequestSchema */ async handleNavigate( request: StagehandHttpRequest, @@ -575,7 +575,7 @@ export class StagehandServer { /** * Handle /sessions/:id/end - End session and cleanup - * Params are pre-validated by Fastify using sessionIdParamsSchema + * Params are pre-validated by Fastify using SessionIdParamsSchema */ async handleEndSession( request: StagehandHttpRequest, diff --git a/packages/core/lib/v3/server/schemas.ts b/packages/core/lib/v3/server/schemas.ts index 868a44317..52b3f92e0 100644 --- a/packages/core/lib/v3/server/schemas.ts +++ b/packages/core/lib/v3/server/schemas.ts @@ -4,6 +4,10 @@ import { z } from "zod"; * Shared Zod schemas for Stagehand Server API * These schemas define the complete API contract between SDK clients and the server. * Used for runtime validation, type inference, and OpenAPI generation. + * + * Naming convention: + * - Schemas: TitleCase with Schema suffix (e.g., ActRequestSchema, ActResponseSchema) + * - Types: TitleCase matching schema name without Schema suffix (e.g., ActRequest, ActResponse) */ // ============================================================================= @@ -11,26 +15,22 @@ import { z } from "zod"; // ============================================================================= /** Standard API success response wrapper */ -export const successResponseSchema = (dataSchema: T) => +export const SuccessResponseSchema = (dataSchema: T) => z.object({ success: z.literal(true), data: dataSchema, }); /** Model configuration for LLM calls (used in action options) */ -export const modelConfigSchema = z.object({ +export const ModelConfigSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), apiKey: z.string().optional(), baseURL: z.string().url().optional(), }); -// ============================================================================= -// Request Headers Schema -// ============================================================================= - /** Headers expected on API requests */ -export const requestHeadersSchema = z.object({ +export const RequestHeadersSchema = z.object({ "x-bb-api-key": z.string().optional(), "x-bb-project-id": z.string().optional(), "x-model-api-key": z.string().optional(), @@ -40,12 +40,26 @@ export const requestHeadersSchema = z.object({ "x-sent-at": z.string().optional(), }); +/** Route params for /sessions/:id/* routes */ +export const SessionIdParamsSchema = z.object({ + id: z.string(), +}); + +/** Action schema - represents a single observable action */ +export const ActionSchema = z.object({ + selector: z.string(), + description: z.string(), + backendNodeId: z.number().optional(), + method: z.string().optional(), + arguments: z.array(z.string()).optional(), +}); + // ============================================================================= -// Session Schemas +// Session Start // ============================================================================= /** POST /v1/sessions/start - Request body */ -export const startSessionRequestSchema = z.object({ +export const SessionStartRequestSchema = z.object({ modelName: z.string(), domSettleTimeoutMs: z.number().optional(), verbose: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), @@ -61,31 +75,35 @@ export const startSessionRequestSchema = z.object({ }); /** Internal result from SessionStore.startSession() - sessionId always present */ -export const startSessionResultSchema = z.object({ +export const SessionStartResultSchema = z.object({ sessionId: z.string(), available: z.boolean(), }); /** POST /v1/sessions/start - HTTP response data (sessionId can be null when unavailable) */ -export const startSessionResponseDataSchema = z.object({ +export const SessionStartResponseDataSchema = z.object({ sessionId: z.string().nullable(), available: z.boolean(), }); /** POST /v1/sessions/start - Full HTTP response */ -export const startSessionResponseSchema = successResponseSchema(startSessionResponseDataSchema); +export const SessionStartResponseSchema = SuccessResponseSchema(SessionStartResponseDataSchema); + +// ============================================================================= +// Session End +// ============================================================================= /** POST /v1/sessions/:id/end - Response */ -export const endSessionResponseSchema = z.object({ +export const SessionEndResponseSchema = z.object({ success: z.literal(true), }); // ============================================================================= -// Action Request Schemas (V3 API) +// Act // ============================================================================= -// Zod schemas for V3 API (we only support V3 in the library server) -export const actSchemaV3 = z.object({ +/** POST /v1/sessions/:id/act - Request body */ +export const ActRequestSchema = z.object({ input: z.string().or( z.object({ selector: z.string(), @@ -97,7 +115,7 @@ export const actSchemaV3 = z.object({ ), options: z .object({ - model: modelConfigSchema.optional(), + model: ModelConfigSchema.optional(), variables: z.record(z.string(), z.string()).optional(), timeout: z.number().optional(), }) @@ -105,12 +123,24 @@ export const actSchemaV3 = z.object({ frameId: z.string().optional(), }); -export const extractSchemaV3 = z.object({ +/** POST /v1/sessions/:id/act - Response */ +export const ActResponseSchema = z.object({ + success: z.boolean(), + message: z.string().optional(), + action: z.string().optional(), +}); + +// ============================================================================= +// Extract +// ============================================================================= + +/** POST /v1/sessions/:id/extract - Request body */ +export const ExtractRequestSchema = z.object({ instruction: z.string().optional(), schema: z.record(z.string(), z.unknown()).optional(), options: z .object({ - model: modelConfigSchema.optional(), + model: ModelConfigSchema.optional(), timeout: z.number().optional(), selector: z.string().optional(), }) @@ -118,11 +148,19 @@ export const extractSchemaV3 = z.object({ frameId: z.string().optional(), }); -export const observeSchemaV3 = z.object({ +/** POST /v1/sessions/:id/extract - Response (dynamic based on user's schema) */ +export const ExtractResponseSchema = z.record(z.string(), z.unknown()); + +// ============================================================================= +// Observe +// ============================================================================= + +/** POST /v1/sessions/:id/observe - Request body */ +export const ObserveRequestSchema = z.object({ instruction: z.string().optional(), options: z .object({ - model: modelConfigSchema.optional(), + model: ModelConfigSchema.optional(), timeout: z.number().optional(), selector: z.string().optional(), }) @@ -130,7 +168,15 @@ export const observeSchemaV3 = z.object({ frameId: z.string().optional(), }); -export const agentExecuteSchemaV3 = z.object({ +/** POST /v1/sessions/:id/observe - Response (array of actions) */ +export const ObserveResponseSchema = z.array(ActionSchema); + +// ============================================================================= +// Agent Execute +// ============================================================================= + +/** POST /v1/sessions/:id/agentExecute - Request body */ +export const AgentExecuteRequestSchema = z.object({ agentConfig: z.object({ provider: z.enum(["openai", "anthropic", "google"]).optional(), model: z @@ -156,7 +202,20 @@ export const agentExecuteSchemaV3 = z.object({ frameId: z.string().optional(), }); -export const navigateSchemaV3 = z.object({ +/** POST /v1/sessions/:id/agentExecute - Response */ +export const AgentExecuteResponseSchema = z.object({ + success: z.boolean(), + message: z.string().optional(), + actions: z.array(z.unknown()).optional(), + completed: z.boolean().optional(), +}); + +// ============================================================================= +// Navigate +// ============================================================================= + +/** POST /v1/sessions/:id/navigate - Request body */ +export const NavigateRequestSchema = z.object({ url: z.string(), options: z .object({ @@ -166,79 +225,44 @@ export const navigateSchemaV3 = z.object({ frameId: z.string().optional(), }); -// ============================================================================= -// Action Response Schemas -// ============================================================================= - -/** Action schema - represents a single observable action */ -export const actionSchema = z.object({ - selector: z.string(), - description: z.string(), - backendNodeId: z.number().optional(), - method: z.string().optional(), - arguments: z.array(z.string()).optional(), -}); - -/** Act result schema */ -export const actResultSchema = z.object({ - success: z.boolean(), - message: z.string().optional(), - action: z.string().optional(), -}); - -/** Extract result schema - dynamic based on user's schema */ -export const extractResultSchema = z.record(z.string(), z.unknown()); - -/** Observe result schema - array of actions */ -export const observeResultSchema = z.array(actionSchema); - -/** Agent result schema */ -export const agentResultSchema = z.object({ - success: z.boolean(), - message: z.string().optional(), - actions: z.array(z.unknown()).optional(), - completed: z.boolean().optional(), -}); - -/** Navigate result schema */ -export const navigateResultSchema = z.object({ +/** POST /v1/sessions/:id/navigate - Response */ +export const NavigateResponseSchema = z.object({ url: z.string().optional(), status: z.number().optional(), }); -// ============================================================================= -// Route Parameter Schemas -// ============================================================================= - -/** Route params for /sessions/:id/* routes */ -export const sessionIdParamsSchema = z.object({ - id: z.string(), -}); - // ============================================================================= // Inferred Types // ============================================================================= -// Request types -export type StartSessionRequest = z.infer; -export type ActRequest = z.infer; -export type ExtractRequest = z.infer; -export type ObserveRequest = z.infer; -export type AgentExecuteRequest = z.infer; -export type NavigateRequest = z.infer; - -// Response types -export type StartSessionResult = z.infer; -export type StartSessionResponseData = z.infer; -export type ActResult = z.infer; -export type ExtractResult = z.infer; -export type ObserveResult = z.infer; -export type AgentResult = z.infer; -export type NavigateResult = z.infer; -export type Action = z.infer; - -// Header types -export type RequestHeaders = z.infer; - -// Route param types -export type SessionIdParams = z.infer; +// Common types +export type ModelConfig = z.infer; +export type RequestHeaders = z.infer; +export type SessionIdParams = z.infer; +export type Action = z.infer; + +// Session types +export type SessionStartRequest = z.infer; +export type SessionStartResult = z.infer; +export type SessionStartResponseData = z.infer; +export type SessionEndResponse = z.infer; + +// Act types +export type ActRequest = z.infer; +export type ActResponse = z.infer; + +// Extract types +export type ExtractRequest = z.infer; +export type ExtractResponse = z.infer; + +// Observe types +export type ObserveRequest = z.infer; +export type ObserveResponse = z.infer; + +// Agent Execute types +export type AgentExecuteRequest = z.infer; +export type AgentExecuteResponse = z.infer; + +// Navigate types +export type NavigateRequest = z.infer; +export type NavigateResponse = z.infer; From 8ef155cd215bb39b79680315a36d29c8668b6060 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:43:42 -0800 Subject: [PATCH 17/30] a few stragglers for names --- packages/core/lib/v3/server/InMemorySessionStore.ts | 4 ++-- packages/core/lib/v3/server/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/lib/v3/server/InMemorySessionStore.ts b/packages/core/lib/v3/server/InMemorySessionStore.ts index 2a12ac507..2f426ddef 100644 --- a/packages/core/lib/v3/server/InMemorySessionStore.ts +++ b/packages/core/lib/v3/server/InMemorySessionStore.ts @@ -6,7 +6,7 @@ import type { CreateSessionParams, RequestContext, SessionCacheConfig, - StartSessionResult, + SessionStartResult, } from "./SessionStore"; const DEFAULT_MAX_CAPACITY = 100; @@ -117,7 +117,7 @@ export class InMemorySessionStore implements SessionStore { await this.deleteSession(lruNode.sessionId); } - async startSession(params: CreateSessionParams): Promise { + async startSession(params: CreateSessionParams): Promise { // Generate session ID or use provided browserbase session ID const sessionId = params.browserbaseSessionID ?? randomUUID(); diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index 6250b2904..ccfb0c954 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -71,7 +71,7 @@ export interface StagehandHttpReply { export * from "./events"; // Re-export SessionStore types -export type { SessionStore, RequestContext, CreateSessionParams, StartSessionResult } from "./SessionStore"; +export type { SessionStore, RequestContext, CreateSessionParams, SessionStartResult } from "./SessionStore"; export { InMemorySessionStore } from "./InMemorySessionStore"; // Re-export API schemas and types for consumers From aaab1a3c645fd8deb2e5c1f2f9791ef51f4e99e1 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:45:11 -0800 Subject: [PATCH 18/30] fix sessionstartnaming --- packages/core/lib/v3/api.ts | 6 +++--- packages/core/lib/v3/types/private/api.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/lib/v3/api.ts b/packages/core/lib/v3/api.ts index 1b0f72bdb..8d8844204 100644 --- a/packages/core/lib/v3/api.ts +++ b/packages/core/lib/v3/api.ts @@ -9,7 +9,7 @@ import { ExecuteActionParams, StagehandAPIConstructorParams, StartSessionParams, - StartSessionResult, + SessionStartResult, } from "./types/private"; import { ActResult, @@ -103,7 +103,7 @@ export class StagehandAPIClient { selfHeal, browserbaseSessionCreateParams, browserbaseSessionID, - }: StartSessionParams): Promise { + }: StartSessionParams): Promise { if (!modelApiKey) { throw new StagehandAPIError("modelApiKey is required"); } @@ -146,7 +146,7 @@ export class StagehandAPIClient { } const sessionResponseBody = - (await sessionResponse.json()) as ApiResponse; + (await sessionResponse.json()) as ApiResponse; if (sessionResponseBody.success === false) { throw new StagehandAPIError(sessionResponseBody.message); diff --git a/packages/core/lib/v3/types/private/api.ts b/packages/core/lib/v3/types/private/api.ts index 3cf3ce03b..e24d7bfd8 100644 --- a/packages/core/lib/v3/types/private/api.ts +++ b/packages/core/lib/v3/types/private/api.ts @@ -51,8 +51,8 @@ export interface StartSessionParams extends Partial { sdkVersion?: string; } -// Re-export StartSessionResult from schemas (defined as Zod schema) -export type { StartSessionResult } from "../../server/schemas"; +// Re-export SessionStartResult from schemas (defined as Zod schema) +export type { SessionStartResult } from "../../server/schemas"; export interface SuccessResponse { success: true; From db4045cc0763cdf2a79f9e19e3029eed7127a0ff Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:47:33 -0800 Subject: [PATCH 19/30] remove dead integration test --- .../integration/p2p-server-client.test.ts | 426 ------------------ 1 file changed, 426 deletions(-) delete mode 100644 packages/core/tests/integration/p2p-server-client.test.ts diff --git a/packages/core/tests/integration/p2p-server-client.test.ts b/packages/core/tests/integration/p2p-server-client.test.ts deleted file mode 100644 index a6b285ebc..000000000 --- a/packages/core/tests/integration/p2p-server-client.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -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)); - - // Point the client at the P2P server via STAGEHAND_API_URL so that it - // uses the HTTP API instead of launching a local browser. - process.env.STAGEHAND_API_URL = `${SERVER_URL}/v1`; - - // Create the client-side Stagehand instance configured to talk to the remote server - clientStagehand = new Stagehand({ - env: "BROWSERBASE", - verbose: 0, - }); - - // Initialize the client, which connects to the remote server - await clientStagehand.init(); - }, 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); - }); -}); From c0a1a7d5be00e91918027159e8bc3351d1139320 Mon Sep 17 00:00:00 2001 From: monadoid Date: Wed, 3 Dec 2025 15:48:10 -0800 Subject: [PATCH 20/30] type fixes --- packages/evals/initV3.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/evals/initV3.ts b/packages/evals/initV3.ts index d601105be..a9558d8cb 100644 --- a/packages/evals/initV3.ts +++ b/packages/evals/initV3.ts @@ -113,7 +113,6 @@ export async function initV3({ browserbaseSessionID: configOverrides?.browserbaseSessionID, selfHeal: true, disablePino: true, - disableAPI: process.env.USE_API !== "true", // Negate: USE_API=true → disableAPI=false logger: logger.log.bind(logger), }; From 7aec8ca6b5220b8f1c868425ef1c370f857769ca Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 15:48:18 -0800 Subject: [PATCH 21/30] remove python examples for now --- .../core/examples/python-client-example.py | 127 ----- packages/core/examples/stagehand.py | 447 ------------------ 2 files changed, 574 deletions(-) delete mode 100644 packages/core/examples/python-client-example.py delete mode 100644 packages/core/examples/stagehand.py diff --git a/packages/core/examples/python-client-example.py b/packages/core/examples/python-client-example.py deleted file mode 100644 index 099cb06e2..000000000 --- a/packages/core/examples/python-client-example.py +++ /dev/null @@ -1,127 +0,0 @@ -#!/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 deleted file mode 100644 index 346cbe43e..000000000 --- a/packages/core/examples/stagehand.py +++ /dev/null @@ -1,447 +0,0 @@ -""" -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()) From 382f6f2612170f1bcc78b816e895e27fe1e585e6 Mon Sep 17 00:00:00 2001 From: monadoid Date: Wed, 3 Dec 2025 16:03:19 -0800 Subject: [PATCH 22/30] added tests (not working yet) --- package.json | 1 - packages/core/package.json | 2 +- packages/core/tests/global-setup.stagehand.ts | 96 +++++++++ .../tests/integration/integration.test.ts | 186 ++++++++++++++++++ .../integration/support/stagehandClient.ts | 77 ++++++++ .../core/tests/vitest.provided-context.d.ts | 8 + packages/core/vitest.config.ts | 22 ++- pnpm-lock.yaml | 43 ++++ 8 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 packages/core/tests/global-setup.stagehand.ts create mode 100644 packages/core/tests/integration/integration.test.ts create mode 100644 packages/core/tests/integration/support/stagehandClient.ts create mode 100644 packages/core/tests/vitest.provided-context.d.ts diff --git a/package.json b/package.json index 52ad35591..469872eca 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "dev": "turbo run dev", "example": "pnpm --filter @browserbasehq/stagehand run example --", "cache:clear": "turbo run build --force", - "prepare": "turbo run build", "release": "turbo run build && changeset publish", "release-canary": "turbo run build && changeset version --snapshot && changeset publish --tag alpha" }, diff --git a/packages/core/package.json b/packages/core/package.json index e2ae661c3..53b9f4c4f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,7 +22,6 @@ "build-dom-scripts": "tsx lib/v3/dom/genDomScripts.ts && tsx lib/v3/dom/genLocatorScripts.ts", "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", "example": "node --import tsx -e \"const args=process.argv.slice(1).filter(a=>a!=='--'); const [p]=args; const n=(p||'example').replace(/^\\.\\//,'').replace(/\\.ts$/i,''); import(new URL(require('node:path').resolve('examples', n + '.ts'), 'file:'));\" --", "test": "playwright test --config=lib/v3/tests/v3.playwright.config.ts", @@ -103,6 +102,7 @@ "tsup": "^8.2.1", "tsx": "^4.10.5", "typescript": "^5.2.2", + "vite": "^5.4.10", "vitest": "^4.0.8", "zod": "3.25.76 || 4.1.8" }, diff --git a/packages/core/tests/global-setup.stagehand.ts b/packages/core/tests/global-setup.stagehand.ts new file mode 100644 index 000000000..46c5109e1 --- /dev/null +++ b/packages/core/tests/global-setup.stagehand.ts @@ -0,0 +1,96 @@ +import type { ProvidedContext } from "vitest"; + +const DEFAULT_REMOTE_URL = "https://api.stagehand.browserbase.com/v1"; +const DEFAULT_LOCAL_PORT = 43123; + +type StagehandGlobalSetupContext = { + provide: ( + key: K, + value: ProvidedContext[K], + ) => void; +}; + +let localServer: { close: () => Promise; getUrl: () => string } | null = + null; +let localServerStagehand: { close: () => Promise } | null = null; + +async function startLocalServer() { + const { Stagehand } = await import("../dist/index.js"); + + const host = process.env.STAGEHAND_LOCAL_HOST ?? "127.0.0.1"; + const port = Number(process.env.STAGEHAND_LOCAL_PORT ?? DEFAULT_LOCAL_PORT); + const serverModel = + process.env.STAGEHAND_SERVER_MODEL ?? "openai/gpt-4o-mini"; + const serverApiKey = + process.env.STAGEHAND_SERVER_MODEL_API_KEY ?? process.env.OPENAI_API_KEY; + + if (!serverApiKey) { + throw new Error( + "Missing STAGEHAND_SERVER_MODEL_API_KEY or OPENAI_API_KEY for local Stagehand server.", + ); + } + + const stagehand = new Stagehand({ + env: "LOCAL", + verbose: 0, + localBrowserLaunchOptions: { + headless: process.env.STAGEHAND_LOCAL_HEADLESS !== "false", + }, + model: serverModel, + }); + + await stagehand.init(); + + const server = stagehand.createServer({ + host, + port, + }); + + await server.listen(); + + localServer = server; + localServerStagehand = stagehand; + + return `http://${host}:${port}/v1`; +} + +export async function setup(ctx: StagehandGlobalSetupContext) { + const target = (process.env.STAGEHAND_TEST_TARGET ?? "local").toLowerCase(); + const normalizedTarget = target === "local" ? "local" : "remote"; + ctx.provide("STAGEHAND_TEST_TARGET", normalizedTarget); + + if (normalizedTarget === "local") { + const baseUrl = await startLocalServer(); + ctx.provide("STAGEHAND_BASE_URL", baseUrl.replace(/\/$/, "")); + return; + } + + const remoteBaseUrl = + process.env.STAGEHAND_BASE_URL ?? + process.env.STAGEHAND_REMOTE_URL ?? + DEFAULT_REMOTE_URL; + + ctx.provide("STAGEHAND_BASE_URL", remoteBaseUrl.replace(/\/$/, "")); +} + +export async function teardown() { + if (localServer) { + try { + await localServer.close(); + } catch { + // + } finally { + localServer = null; + } + } + + if (localServerStagehand) { + try { + await localServerStagehand.close(); + } catch { + // + } finally { + localServerStagehand = null; + } + } +} diff --git a/packages/core/tests/integration/integration.test.ts b/packages/core/tests/integration/integration.test.ts new file mode 100644 index 000000000..f8314cfed --- /dev/null +++ b/packages/core/tests/integration/integration.test.ts @@ -0,0 +1,186 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import * as Stagehand from "../../dist/index.js"; +import { z } from "zod"; +import { + createStagehandHarness, + getMissingClientEnvVars, + resolveTestTarget, +} from "./support/stagehandClient"; + +const testSite = + process.env.STAGEHAND_EVAL_URL ?? + "https://browserbase.github.io/stagehand-eval-sites/sites/iframe-hn/"; +const agentModel = + process.env.STAGEHAND_AGENT_MODEL ?? "openai/gpt-4o-mini"; +const TEST_TIMEOUT = 180_000; + +describe.sequential("Stagehand thin-client integration", () => { + const testTarget = resolveTestTarget(); + const isRemoteTarget = testTarget === "remote"; + + let stagehand: Stagehand.Stagehand; + let activePage: Stagehand.Page; + + beforeAll( + async () => { + const missing = getMissingClientEnvVars(testTarget); + if (missing.length > 0) { + throw new Error( + `Missing required env vars for Stagehand integration tests: ${missing.join( + ", ", + )}`, + ); + } + + const harness = createStagehandHarness(testTarget); + stagehand = harness.stagehand; + + await stagehand.init(); + activePage = + stagehand.context.pages()[0] ?? (await stagehand.context.newPage()); + }, + TEST_TIMEOUT, + ); + + afterAll(async () => { + if (stagehand) { + await stagehand.close().catch(() => {}); + } + }); + + beforeEach(async () => { + const defaultPage = stagehand.context.pages()[0]; + if (defaultPage) { + activePage = defaultPage; + return; + } + activePage = await stagehand.context.newPage(); + }); + + describe("start", () => { + it( + "creates Browserbase sessions through the thin client", + { timeout: TEST_TIMEOUT }, + async () => { + expect(stagehand.browserbaseSessionID).toBeTruthy(); + if (isRemoteTarget) { + expect(stagehand.browserbaseSessionURL).toMatch( + /^https:\/\/www\.browserbase\.com\/sessions\//, + ); + } else { + expect(stagehand.browserbaseSessionURL).toBeUndefined(); + } + expect(stagehand.context.pages().length).toBeGreaterThan(0); + }, + ); + }); + + describe("navigate", () => { + it( + "navigates remote pages via /navigate", + { timeout: TEST_TIMEOUT }, + async () => { + const response = await activePage.goto(testSite, { + waitUntil: "domcontentloaded", + }); + expect(response?.ok()).toBe(true); + expect(activePage.url()).toContain("iframe-hn"); + }, + ); + }); + + describe("extract", () => { + it( + "extracts structured data through /extract", + { timeout: TEST_TIMEOUT, retry: 1 }, + async () => { + await activePage.goto(testSite, { waitUntil: "domcontentloaded" }); + const summarySchema = z.object({ + topStory: z.string(), + }); + + const extraction = await stagehand.extract( + "Return only the first visible story headline text.", + summarySchema, + ); + + expect(extraction.topStory.length).toBeGreaterThan(5); + }, + ); + }); + + describe("observe", () => { + it( + "observes actionable elements and replays them via /observe and /act", + { timeout: TEST_TIMEOUT, retry: 1 }, + async () => { + await activePage.goto(testSite, { waitUntil: "domcontentloaded" }); + const [action] = await stagehand.observe( + "Provide a single action that clicks the navigation link labeled 'new'.", + ); + + expect(action).toBeDefined(); + expect(action.selector.length).toBeGreaterThan(0); + + const actResult = await stagehand.act(action); + expect(actResult.success).toBe(true); + expect(actResult.actions.length).toBeGreaterThan(0); + }, + ); + }); + + describe("act", () => { + // Tests for act endpoint would go here + }); + + describe("agentExecute", () => { + it( + "executes hosted agents through /agentExecute", + { timeout: 240_000, retry: 1 }, + async () => { + await activePage.goto(testSite, { waitUntil: "domcontentloaded" }); + const agent = stagehand.agent({ + model: agentModel, + cua: false, + systemPrompt: + "Keep answers short. Stop once you confirm a headline is visible.", + }); + + const result = await agent.execute({ + instruction: + "Read the current page's title and acknowledge one top story before stopping.", + maxSteps: 4, + }); + + expect(result.success).toBe(true); + expect(result.actions.length).toBeGreaterThan(0); + }, + ); + }); + + describe("replay", () => { + it( + "exposes replay metrics via /replay", + { timeout: TEST_TIMEOUT }, + async () => { + const metrics = await stagehand.metrics; + expect(metrics.totalPromptTokens).toBeGreaterThan(0); + expect(metrics.totalInferenceTimeMs).toBeGreaterThan(0); + }, + ); + }); + + describe("end", () => { + it( + "terminates Browserbase sessions through /end", + { timeout: TEST_TIMEOUT }, + async () => { + const { stagehand: ephemeral } = createStagehandHarness(testTarget); + await ephemeral.init(); + expect(ephemeral.browserbaseSessionID).toBeTruthy(); + await ephemeral.close(); + expect(ephemeral.browserbaseSessionID).toBeUndefined(); + }, + ); + }); +}); diff --git a/packages/core/tests/integration/support/stagehandClient.ts b/packages/core/tests/integration/support/stagehandClient.ts new file mode 100644 index 000000000..2b1cbebce --- /dev/null +++ b/packages/core/tests/integration/support/stagehandClient.ts @@ -0,0 +1,77 @@ +import { inject } from "vitest"; +import * as Stagehand from "../../../dist/index.js"; + +type TestTarget = "remote" | "local"; + +const REMOTE_REQUIRED_VARS = [ + "BROWSERBASE_API_KEY", + "BROWSERBASE_PROJECT_ID", + "OPENAI_API_KEY", +]; + +const LOCAL_REQUIRED_VARS = ["OPENAI_API_KEY"]; + +export function resolveTestTarget(): TestTarget { + const providedTarget = inject( + "STAGEHAND_TEST_TARGET", + ) as string | undefined; + const normalized = + providedTarget ?? process.env.STAGEHAND_TEST_TARGET ?? "local"; + return normalized.toLowerCase() === "local" ? "local" : "remote"; +} + +export function getMissingClientEnvVars(target: TestTarget): string[] { + const required = + target === "remote" ? REMOTE_REQUIRED_VARS : LOCAL_REQUIRED_VARS; + return required.filter((name) => { + const value = process.env[name]; + return !value || value.length === 0; + }); +} + +export function createStagehandHarness(target?: TestTarget) { + const activeTarget = target ?? resolveTestTarget(); + const providedBaseUrl = inject("STAGEHAND_BASE_URL") as string | undefined; + if (!providedBaseUrl) { + throw new Error("STAGEHAND_BASE_URL was not provided by globalSetup."); + } + + const normalizedBaseUrl = providedBaseUrl.endsWith("/v1") + ? providedBaseUrl + : `${providedBaseUrl.replace(/\/$/, "")}/v1`; + + process.env.STAGEHAND_API_URL = normalizedBaseUrl; + + const stagehandOptions: Record = { + env: "BROWSERBASE", + verbose: 0, + disableAPI: false, + experimental: false, + logInferenceToFile: false, + }; + + if (activeTarget === "local") { + const clientApiKey = + process.env.STAGEHAND_CLIENT_MODEL_API_KEY ?? process.env.OPENAI_API_KEY; + if (!clientApiKey) { + throw new Error( + "Missing STAGEHAND_CLIENT_MODEL_API_KEY or OPENAI_API_KEY for local client.", + ); + } + + stagehandOptions.model = + process.env.STAGEHAND_CLIENT_MODEL ?? + process.env.STAGEHAND_SERVER_MODEL ?? + "openai/gpt-4o-mini"; + stagehandOptions.modelClientOptions = { + apiKey: clientApiKey, + baseURL: process.env.STAGEHAND_CLIENT_MODEL_BASE_URL, + }; + } + + const stagehand = new Stagehand.Stagehand(stagehandOptions); + + const apiRootUrl = normalizedBaseUrl.replace(/\/v1\/?$/, ""); + + return { stagehand, apiRootUrl, target: activeTarget }; +} diff --git a/packages/core/tests/vitest.provided-context.d.ts b/packages/core/tests/vitest.provided-context.d.ts new file mode 100644 index 000000000..d46b4f976 --- /dev/null +++ b/packages/core/tests/vitest.provided-context.d.ts @@ -0,0 +1,8 @@ +import "vitest"; + +declare module "vitest" { + interface ProvidedContext { + STAGEHAND_BASE_URL: string; + STAGEHAND_TEST_TARGET: string; + } +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index faa6d98e1..9d8dd40ae 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,8 +1,20 @@ import { defineConfig } from "vitest/config"; +import { loadEnv } from "vite"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; -export default defineConfig({ - test: { - environment: "node", - include: ["tests/**/*.test.ts"], - }, +const configDir = dirname(fileURLToPath(import.meta.url)); +const envDir = resolve(configDir, "tests"); + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, envDir, ""); + + return { + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + env, + globalSetup: ["./tests/global-setup.stagehand.ts"], + }, + }; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc1f26cea..f009311ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,9 @@ importers: typescript: specifier: ^5.2.2 version: 5.8.3 + vite: + specifier: ^5.4.10 + version: 5.4.21(@types/node@20.17.32) 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) @@ -6287,6 +6290,37 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.2.2: resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -13781,6 +13815,15 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + vite@5.4.21(@types/node@20.17.32): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.53.2 + optionalDependencies: + '@types/node': 20.17.32 + fsevents: 2.3.3 + vite@7.2.2(@types/node@20.17.32)(jiti@1.21.7)(tsx@4.19.4)(yaml@2.7.1): dependencies: esbuild: 0.25.11 From dfcb1dec00d26bef3c9ca4a59f05bb618dae02cf Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 16:08:48 -0800 Subject: [PATCH 23/30] use generics to pass down inferred req and resp strict types --- packages/core/lib/v3/server/index.ts | 202 +++++++++++++++++++------ packages/core/lib/v3/server/schemas.ts | 4 + 2 files changed, 163 insertions(+), 43 deletions(-) diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index ccfb0c954..df9c35377 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -1,4 +1,4 @@ -import Fastify, { FastifyInstance } from "fastify"; +import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import cors from "@fastify/cors"; import { serializerCompiler, @@ -22,12 +22,19 @@ import { InMemorySessionStore } from "./InMemorySessionStore"; import { createStreamingResponse } from "./stream"; import { ActRequestSchema, + ActResponseSchema, ExtractRequestSchema, + ExtractResponseSchema, ObserveRequestSchema, + ObserveResponseSchema, AgentExecuteRequestSchema, + AgentExecuteResponseSchema, NavigateRequestSchema, + NavigateResponseSchema, SessionStartRequestSchema, + SessionStartResponseSchema, SessionIdParamsSchema, + SessionEndResponseSchema, type SessionStartRequest, type ActRequest, type ExtractRequest, @@ -44,11 +51,18 @@ import { /** * Generic HTTP request interface. * Structurally compatible with FastifyRequest from any version. + * @template TBody - Type of the request body (defaults to unknown for backwards compatibility) + * @template TParams - Type of the route params (defaults to unknown for backwards compatibility) */ -export interface StagehandHttpRequest { +export interface StagehandHttpRequest { headers: Record; - body: unknown; - params: unknown; + body: TBody; + params: TParams; + /** + * The validated session ID, set by sessionValidationPreHandler. + * Only available on routes that use the preHandler. + */ + validatedSessionId?: string; } /** @@ -77,6 +91,84 @@ export { InMemorySessionStore } from "./InMemorySessionStore"; // Re-export API schemas and types for consumers export * from "./schemas"; +// ============================================================================= +// Standalone PreHandler Factory Functions +// ============================================================================= + +/** + * Generic preHandler request interface that works across Fastify versions. + * Uses structural typing to be compatible with any Fastify version. + */ +export interface PreHandlerRequest { + params: unknown; +} + +/** + * Generic preHandler reply interface that works across Fastify versions. + * Uses structural typing to be compatible with any Fastify version. + */ +export interface PreHandlerReply { + status(code: number): PreHandlerReply; + send(payload: unknown): unknown; +} + +/** + * Type for a session validation preHandler function. + * Generic to work across different Fastify versions. + */ +export type SessionValidationPreHandler = ( + request: PreHandlerRequest, + reply: PreHandlerReply, +) => Promise; + +/** + * Creates a preHandler that validates the session ID param exists and session is found in SessionStore. + * Attaches the validated session ID to request.validatedSessionId for downstream use. + * + * This factory function can be used by external servers (e.g., cloud API) that want to + * share the same validation logic as the StagehandServer. + * + * @param sessionStore - The SessionStore instance to use for validation + * @returns A preHandler function compatible with any Fastify version + * + * @example + * ```typescript + * import { createSessionValidationPreHandler, DBSessionStore } from '@browserbasehq/stagehand/server'; + * + * const sessionStore = new DBSessionStore(); + * const sessionValidationPreHandler = createSessionValidationPreHandler(sessionStore); + * + * app.post('/sessions/:id/act', { + * preHandler: [sessionValidationPreHandler], + * }, handler); + * ``` + */ +export function createSessionValidationPreHandler( + sessionStore: SessionStore, +): SessionValidationPreHandler { + return async (request: PreHandlerRequest, reply: PreHandlerReply): Promise => { + const { id } = request.params as { id?: string }; + + if (!id?.length) { + reply.status(400).send({ + error: "Missing session id", + }); + return; + } + + const hasSession = await sessionStore.hasSession(id); + if (!hasSession) { + reply.status(404).send({ + error: "Session not found", + }); + return; + } + + // Attach validated session ID to request for downstream handlers + (request as unknown as StagehandHttpRequest).validatedSessionId = id; + }; +} + export interface StagehandServerOptions { port?: number; host?: string; @@ -136,8 +228,18 @@ export class StagehandServer { }); } + /** + * Creates a preHandler that validates the session ID param exists and session is found in SessionStore. + * Attaches the validated session ID to request.validatedSessionId for downstream use. + * This is also exported as a standalone factory function for use by external servers (e.g., cloud API). + */ + private createSessionValidationPreHandler(): SessionValidationPreHandler { + return createSessionValidationPreHandler(this.sessionStore); + } + private setupRoutes(): void { const app = this.app.withTypeProvider(); + const sessionValidationPreHandler = this.createSessionValidationPreHandler(); // Health check app.get("/health", async () => { @@ -150,6 +252,9 @@ export class StagehandServer { { schema: { body: SessionStartRequestSchema, + response: { + 200: SessionStartResponseSchema, + }, }, }, async (request, reply) => { @@ -164,7 +269,11 @@ export class StagehandServer { schema: { params: SessionIdParamsSchema, body: ActRequestSchema, + response: { + 200: ActResponseSchema, + }, }, + preHandler: [sessionValidationPreHandler], }, async (request, reply) => { return this.handleAct(request, reply); @@ -178,7 +287,11 @@ export class StagehandServer { schema: { params: SessionIdParamsSchema, body: ExtractRequestSchema, + response: { + 200: ExtractResponseSchema, + }, }, + preHandler: [sessionValidationPreHandler], }, async (request, reply) => { return this.handleExtract(request, reply); @@ -192,7 +305,11 @@ export class StagehandServer { schema: { params: SessionIdParamsSchema, body: ObserveRequestSchema, + response: { + 200: ObserveResponseSchema, + }, }, + preHandler: [sessionValidationPreHandler], }, async (request, reply) => { return this.handleObserve(request, reply); @@ -206,7 +323,11 @@ export class StagehandServer { schema: { params: SessionIdParamsSchema, body: AgentExecuteRequestSchema, + response: { + 200: AgentExecuteResponseSchema, + }, }, + preHandler: [sessionValidationPreHandler], }, async (request, reply) => { return this.handleAgentExecute(request, reply); @@ -220,7 +341,11 @@ export class StagehandServer { schema: { params: SessionIdParamsSchema, body: NavigateRequestSchema, + response: { + 200: NavigateResponseSchema, + }, }, + preHandler: [sessionValidationPreHandler], }, async (request, reply) => { return this.handleNavigate(request, reply); @@ -233,7 +358,11 @@ export class StagehandServer { { schema: { params: SessionIdParamsSchema, + response: { + 200: SessionEndResponseSchema, + }, }, + preHandler: [sessionValidationPreHandler], }, async (request, reply) => { return this.handleEndSession(request, reply); @@ -246,11 +375,11 @@ export class StagehandServer { * Body is pre-validated by Fastify using SessionStartRequestSchema */ async handleStartSession( - request: StagehandHttpRequest, + request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { try { - const body = request.body as SessionStartRequest; + const { body } = request; const createParams: CreateSessionParams = { modelName: body.modelName, @@ -290,17 +419,14 @@ export class StagehandServer { /** * Handle /sessions/:id/act - Execute act command * Body is pre-validated by Fastify using ActRequestSchema + * Session is pre-validated by sessionValidationPreHandler */ async handleAct( - request: StagehandHttpRequest, + request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as SessionIdParams; - - if (!(await this.sessionStore.hasSession(sessionId))) { - reply.status(404).send({ error: "Session not found" }); - return; - } + // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params + const sessionId = request.validatedSessionId ?? request.params.id; const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, @@ -351,17 +477,14 @@ export class StagehandServer { /** * Handle /sessions/:id/extract - Execute extract command * Body is pre-validated by Fastify using ExtractRequestSchema + * Session is pre-validated by sessionValidationPreHandler */ async handleExtract( - request: StagehandHttpRequest, + request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as SessionIdParams; - - if (!(await this.sessionStore.hasSession(sessionId))) { - reply.status(404).send({ error: "Session not found" }); - return; - } + // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params + const sessionId = request.validatedSessionId ?? request.params.id; const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, @@ -420,17 +543,14 @@ export class StagehandServer { /** * Handle /sessions/:id/observe - Execute observe command * Body is pre-validated by Fastify using ObserveRequestSchema + * Session is pre-validated by sessionValidationPreHandler */ async handleObserve( - request: StagehandHttpRequest, + request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as SessionIdParams; - - if (!(await this.sessionStore.hasSession(sessionId))) { - reply.status(404).send({ error: "Session not found" }); - return; - } + // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params + const sessionId = request.validatedSessionId ?? request.params.id; const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, @@ -483,17 +603,14 @@ export class StagehandServer { /** * Handle /sessions/:id/agentExecute - Execute agent command * Body is pre-validated by Fastify using AgentExecuteRequestSchema + * Session is pre-validated by sessionValidationPreHandler */ async handleAgentExecute( - request: StagehandHttpRequest, + request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as SessionIdParams; - - if (!(await this.sessionStore.hasSession(sessionId))) { - reply.status(404).send({ error: "Session not found" }); - return; - } + // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params + const sessionId = request.validatedSessionId ?? request.params.id; const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, @@ -532,17 +649,14 @@ export class StagehandServer { /** * Handle /sessions/:id/navigate - Navigate to URL * Body is pre-validated by Fastify using NavigateRequestSchema + * Session is pre-validated by sessionValidationPreHandler */ async handleNavigate( - request: StagehandHttpRequest, + request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as SessionIdParams; - - if (!(await this.sessionStore.hasSession(sessionId))) { - reply.status(404).send({ error: "Session not found" }); - return; - } + // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params + const sessionId = request.validatedSessionId ?? request.params.id; const ctx: RequestContext = { modelApiKey: request.headers["x-model-api-key"] as string | undefined, @@ -576,12 +690,14 @@ export class StagehandServer { /** * Handle /sessions/:id/end - End session and cleanup * Params are pre-validated by Fastify using SessionIdParamsSchema + * Session is pre-validated by sessionValidationPreHandler */ async handleEndSession( - request: StagehandHttpRequest, + request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - const { id: sessionId } = request.params as SessionIdParams; + // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params + const sessionId = request.validatedSessionId ?? request.params.id; try { await this.sessionStore.endSession(sessionId); diff --git a/packages/core/lib/v3/server/schemas.ts b/packages/core/lib/v3/server/schemas.ts index 52b3f92e0..e0e1f9ddc 100644 --- a/packages/core/lib/v3/server/schemas.ts +++ b/packages/core/lib/v3/server/schemas.ts @@ -93,6 +93,9 @@ export const SessionStartResponseSchema = SuccessResponseSchema(SessionStartResp // Session End // ============================================================================= +/** POST /v1/sessions/:id/end - Request body (empty, session ID comes from params) */ +export const SessionEndRequestSchema = z.object({}); + /** POST /v1/sessions/:id/end - Response */ export const SessionEndResponseSchema = z.object({ success: z.literal(true), @@ -245,6 +248,7 @@ export type Action = z.infer; export type SessionStartRequest = z.infer; export type SessionStartResult = z.infer; export type SessionStartResponseData = z.infer; +export type SessionEndRequest = z.infer; export type SessionEndResponse = z.infer; // Act types From 1227a2fc549207071d61a453a8cbac4aebe6cd05 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 16:09:36 -0800 Subject: [PATCH 24/30] type the End request as well --- packages/core/lib/v3/server/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index df9c35377..d94827f48 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -34,8 +34,10 @@ import { SessionStartRequestSchema, SessionStartResponseSchema, SessionIdParamsSchema, + SessionEndRequestSchema, SessionEndResponseSchema, type SessionStartRequest, + type SessionEndRequest, type ActRequest, type ExtractRequest, type ObserveRequest, @@ -358,6 +360,7 @@ export class StagehandServer { { schema: { params: SessionIdParamsSchema, + body: SessionEndRequestSchema, response: { 200: SessionEndResponseSchema, }, @@ -693,7 +696,7 @@ export class StagehandServer { * Session is pre-validated by sessionValidationPreHandler */ async handleEndSession( - request: StagehandHttpRequest, + request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params From b1703a43317e5863bb3459ffb7939dc31c72a009 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 16:13:45 -0800 Subject: [PATCH 25/30] cleanup session validation precheck --- packages/core/lib/v3/server/index.ts | 96 +++++++++++++--------------- 1 file changed, 44 insertions(+), 52 deletions(-) diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index d94827f48..a12805912 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -98,30 +98,43 @@ export * from "./schemas"; // ============================================================================= /** - * Generic preHandler request interface that works across Fastify versions. - * Uses structural typing to be compatible with any Fastify version. + * Validates the session ID param exists and session is found in SessionStore. + * Attaches the validated session ID to request.validatedSessionId for downstream use. + * + * This is the core validation logic that can be used by external servers (e.g., cloud API) + * to share the same validation as the StagehandServer. + * + * @param sessionStore - The SessionStore instance to use for validation + * @param request - The request object (must have params with id) + * @param reply - The reply object (must have status and send methods) + * @returns true if validation passed, false if response was sent */ -export interface PreHandlerRequest { - params: unknown; -} +export async function validateSession( + sessionStore: SessionStore, + request: { params: unknown }, + reply: { status(code: number): { send(payload: unknown): unknown } }, +): Promise { + const { id } = request.params as { id?: string }; + + if (!id?.length) { + reply.status(400).send({ + error: "Missing session id", + }); + return false; + } -/** - * Generic preHandler reply interface that works across Fastify versions. - * Uses structural typing to be compatible with any Fastify version. - */ -export interface PreHandlerReply { - status(code: number): PreHandlerReply; - send(payload: unknown): unknown; -} + const hasSession = await sessionStore.hasSession(id); + if (!hasSession) { + reply.status(404).send({ + error: "Session not found", + }); + return false; + } -/** - * Type for a session validation preHandler function. - * Generic to work across different Fastify versions. - */ -export type SessionValidationPreHandler = ( - request: PreHandlerRequest, - reply: PreHandlerReply, -) => Promise; + // Attach validated session ID to request for downstream handlers + (request as StagehandHttpRequest).validatedSessionId = id; + return true; +} /** * Creates a preHandler that validates the session ID param exists and session is found in SessionStore. @@ -147,27 +160,15 @@ export type SessionValidationPreHandler = ( */ export function createSessionValidationPreHandler( sessionStore: SessionStore, -): SessionValidationPreHandler { - return async (request: PreHandlerRequest, reply: PreHandlerReply): Promise => { - const { id } = request.params as { id?: string }; - - if (!id?.length) { - reply.status(400).send({ - error: "Missing session id", - }); - return; - } - - const hasSession = await sessionStore.hasSession(id); - if (!hasSession) { - reply.status(404).send({ - error: "Session not found", - }); - return; - } - - // Attach validated session ID to request for downstream handlers - (request as unknown as StagehandHttpRequest).validatedSessionId = id; +): ( + request: { params: unknown }, + reply: { status(code: number): { send(payload: unknown): unknown } }, +) => Promise { + return async ( + request: { params: unknown }, + reply: { status(code: number): { send(payload: unknown): unknown } }, + ): Promise => { + await validateSession(sessionStore, request, reply); }; } @@ -230,18 +231,9 @@ export class StagehandServer { }); } - /** - * Creates a preHandler that validates the session ID param exists and session is found in SessionStore. - * Attaches the validated session ID to request.validatedSessionId for downstream use. - * This is also exported as a standalone factory function for use by external servers (e.g., cloud API). - */ - private createSessionValidationPreHandler(): SessionValidationPreHandler { - return createSessionValidationPreHandler(this.sessionStore); - } - private setupRoutes(): void { const app = this.app.withTypeProvider(); - const sessionValidationPreHandler = this.createSessionValidationPreHandler(); + const sessionValidationPreHandler = createSessionValidationPreHandler(this.sessionStore); // Health check app.get("/health", async () => { From c83c0130f034fbea557c597385127596a89bdf09 Mon Sep 17 00:00:00 2001 From: monadoid Date: Wed, 3 Dec 2025 16:20:31 -0800 Subject: [PATCH 26/30] Fixed params in vitest --- packages/core/tests/global-setup.stagehand.ts | 10 +++-- .../integration/support/stagehandClient.ts | 45 ++++++++----------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/packages/core/tests/global-setup.stagehand.ts b/packages/core/tests/global-setup.stagehand.ts index 46c5109e1..ca51d44ef 100644 --- a/packages/core/tests/global-setup.stagehand.ts +++ b/packages/core/tests/global-setup.stagehand.ts @@ -21,12 +21,11 @@ async function startLocalServer() { const port = Number(process.env.STAGEHAND_LOCAL_PORT ?? DEFAULT_LOCAL_PORT); const serverModel = process.env.STAGEHAND_SERVER_MODEL ?? "openai/gpt-4o-mini"; - const serverApiKey = - process.env.STAGEHAND_SERVER_MODEL_API_KEY ?? process.env.OPENAI_API_KEY; + const serverApiKey = process.env.OPENAI_API_KEY; if (!serverApiKey) { throw new Error( - "Missing STAGEHAND_SERVER_MODEL_API_KEY or OPENAI_API_KEY for local Stagehand server.", + "Missing OPENAI_API_KEY for local Stagehand server.", ); } @@ -36,7 +35,10 @@ async function startLocalServer() { localBrowserLaunchOptions: { headless: process.env.STAGEHAND_LOCAL_HEADLESS !== "false", }, - model: serverModel, + model: { + modelName: serverModel, + apiKey: serverApiKey, + }, }); await stagehand.init(); diff --git a/packages/core/tests/integration/support/stagehandClient.ts b/packages/core/tests/integration/support/stagehandClient.ts index 2b1cbebce..76c6fa913 100644 --- a/packages/core/tests/integration/support/stagehandClient.ts +++ b/packages/core/tests/integration/support/stagehandClient.ts @@ -37,41 +37,34 @@ export function createStagehandHarness(target?: TestTarget) { } const normalizedBaseUrl = providedBaseUrl.endsWith("/v1") - ? providedBaseUrl - : `${providedBaseUrl.replace(/\/$/, "")}/v1`; + ? providedBaseUrl + : `${providedBaseUrl.replace(/\/$/, "")}/v1`; process.env.STAGEHAND_API_URL = normalizedBaseUrl; - const stagehandOptions: Record = { - env: "BROWSERBASE", - verbose: 0, - disableAPI: false, - experimental: false, - logInferenceToFile: false, - }; + const stagehandOptions: Record = {}; if (activeTarget === "local") { - const clientApiKey = - process.env.STAGEHAND_CLIENT_MODEL_API_KEY ?? process.env.OPENAI_API_KEY; + const clientApiKey = process.env.OPENAI_API_KEY; if (!clientApiKey) { throw new Error( - "Missing STAGEHAND_CLIENT_MODEL_API_KEY or OPENAI_API_KEY for local client.", + "Missing OPENAI_API_KEY for local client.", ); } - stagehandOptions.model = - process.env.STAGEHAND_CLIENT_MODEL ?? - process.env.STAGEHAND_SERVER_MODEL ?? - "openai/gpt-4o-mini"; - stagehandOptions.modelClientOptions = { - apiKey: clientApiKey, - baseURL: process.env.STAGEHAND_CLIENT_MODEL_BASE_URL, - }; - } - - const stagehand = new Stagehand.Stagehand(stagehandOptions); + const stagehand = new Stagehand.Stagehand({ + env: "BROWSERBASE", + verbose: 0, + model: { + modelName: "openai/gpt-5-mini", + apiKey: clientApiKey, + }, + experimental: false, + logInferenceToFile: false, + }); - const apiRootUrl = normalizedBaseUrl.replace(/\/v1\/?$/, ""); + const apiRootUrl = normalizedBaseUrl.replace(/\/v1\/?$/, ""); - return { stagehand, apiRootUrl, target: activeTarget }; -} + return {stagehand, apiRootUrl, target: activeTarget}; + } +} \ No newline at end of file From cc14f606479c634b79e677d5ebd42f0ecc624c8f Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 16:46:55 -0800 Subject: [PATCH 27/30] properly handle errors in library stream --- packages/core/lib/v3/server/index.ts | 93 ++++------- packages/core/lib/v3/server/stream.ts | 212 ++++++++++++++++++++++++-- 2 files changed, 231 insertions(+), 74 deletions(-) diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index a12805912..a6200c00e 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -19,7 +19,11 @@ import type { StagehandZodSchema } from "../zodCompat"; import { jsonSchemaToZod, type JsonSchema } from "../../utils"; import type { SessionStore, RequestContext, CreateSessionParams } from "./SessionStore"; import { InMemorySessionStore } from "./InMemorySessionStore"; -import { createStreamingResponse } from "./stream"; +import { createStreamingResponse, mapStagehandError } from "./stream"; + +// Re-export error handling utilities for consumers +export { StagehandErrorCode, mapStagehandError } from "./stream"; +export type { StagehandErrorResponse } from "./stream"; import { ActRequestSchema, ActResponseSchema, @@ -60,11 +64,6 @@ export interface StagehandHttpRequest { headers: Record; body: TBody; params: TParams; - /** - * The validated session ID, set by sessionValidationPreHandler. - * Only available on routes that use the preHandler. - */ - validatedSessionId?: string; } /** @@ -99,7 +98,6 @@ export * from "./schemas"; /** * Validates the session ID param exists and session is found in SessionStore. - * Attaches the validated session ID to request.validatedSessionId for downstream use. * * This is the core validation logic that can be used by external servers (e.g., cloud API) * to share the same validation as the StagehandServer. @@ -131,14 +129,11 @@ export async function validateSession( return false; } - // Attach validated session ID to request for downstream handlers - (request as StagehandHttpRequest).validatedSessionId = id; return true; } /** * Creates a preHandler that validates the session ID param exists and session is found in SessionStore. - * Attaches the validated session ID to request.validatedSessionId for downstream use. * * This factory function can be used by external servers (e.g., cloud API) that want to * share the same validation logic as the StagehandServer. @@ -404,9 +399,14 @@ export class StagehandServer { }, }); } catch (error) { - reply.status(500).send({ + const mappedError = mapStagehandError( + error instanceof Error ? error : new Error("Failed to create session"), + "startSession", + ); + reply.status(mappedError.statusCode).send({ success: false, - message: error instanceof Error ? error.message : "Failed to create session", + error: mappedError.error, + code: mappedError.code, }); } } @@ -420,19 +420,12 @@ export class StagehandServer { request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params - const sessionId = request.validatedSessionId ?? request.params.id; - - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; - await createStreamingResponse({ - sessionId, + sessionId: request.params.id, sessionStore: this.sessionStore, - requestContext: ctx, request, reply, + operation: "act", handler: async (handlerCtx, data) => { const stagehand = handlerCtx.stagehand as any; const { frameId } = data; @@ -478,19 +471,12 @@ export class StagehandServer { request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params - const sessionId = request.validatedSessionId ?? request.params.id; - - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; - await createStreamingResponse({ - sessionId, + sessionId: request.params.id, sessionStore: this.sessionStore, - requestContext: ctx, request, reply, + operation: "extract", handler: async (handlerCtx, data) => { const stagehand = handlerCtx.stagehand as any; const { frameId } = data; @@ -544,19 +530,12 @@ export class StagehandServer { request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params - const sessionId = request.validatedSessionId ?? request.params.id; - - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; - await createStreamingResponse({ - sessionId, + sessionId: request.params.id, sessionStore: this.sessionStore, - requestContext: ctx, request, reply, + operation: "observe", handler: async (handlerCtx, data) => { const stagehand = handlerCtx.stagehand as any; const { frameId } = data; @@ -604,19 +583,12 @@ export class StagehandServer { request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params - const sessionId = request.validatedSessionId ?? request.params.id; - - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; - await createStreamingResponse({ - sessionId, + sessionId: request.params.id, sessionStore: this.sessionStore, - requestContext: ctx, request, reply, + operation: "agentExecute", handler: async (handlerCtx, data) => { const stagehand = handlerCtx.stagehand as any; const { agentConfig, executeOptions, frameId } = data; @@ -650,19 +622,12 @@ export class StagehandServer { request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params - const sessionId = request.validatedSessionId ?? request.params.id; - - const ctx: RequestContext = { - modelApiKey: request.headers["x-model-api-key"] as string | undefined, - }; - await createStreamingResponse({ - sessionId, + sessionId: request.params.id, sessionStore: this.sessionStore, - requestContext: ctx, request, reply, + operation: "navigate", handler: async (handlerCtx, data) => { const stagehand = handlerCtx.stagehand as any; const { url, options, frameId } = data; @@ -691,15 +656,17 @@ export class StagehandServer { request: StagehandHttpRequest, reply: StagehandHttpReply, ): Promise { - // Session ID is validated by preHandler, use validatedSessionId if available, fallback to params - const sessionId = request.validatedSessionId ?? request.params.id; - try { - await this.sessionStore.endSession(sessionId); + await this.sessionStore.endSession(request.params.id); reply.status(200).send({ success: true }); } catch (error) { - reply.status(500).send({ - error: error instanceof Error ? error.message : "Failed to end session", + const mappedError = mapStagehandError( + error instanceof Error ? error : new Error("Failed to end session"), + "endSession", + ); + reply.status(mappedError.statusCode).send({ + error: mappedError.error, + code: mappedError.code, }); } } diff --git a/packages/core/lib/v3/server/stream.ts b/packages/core/lib/v3/server/stream.ts index 16822ee2e..16b7e0204 100644 --- a/packages/core/lib/v3/server/stream.ts +++ b/packages/core/lib/v3/server/stream.ts @@ -2,6 +2,191 @@ import { randomUUID } from "crypto"; import type { V3 } from "../v3"; import type { SessionStore, RequestContext } from "./SessionStore"; +// ============================================================================= +// Error Handling +// ============================================================================= + +/** + * Standard error codes for Stagehand API errors. + */ +export enum StagehandErrorCode { + // User-actionable errors (400) + INVALID_ARGUMENT = "INVALID_ARGUMENT", + MISSING_ARGUMENT = "MISSING_ARGUMENT", + INVALID_MODEL = "INVALID_MODEL", + INVALID_SCHEMA = "INVALID_SCHEMA", + EXPERIMENTAL_NOT_CONFIGURED = "EXPERIMENTAL_NOT_CONFIGURED", + + // Operational errors (422) + ELEMENT_NOT_FOUND = "ELEMENT_NOT_FOUND", + ACTION_FAILED = "ACTION_FAILED", + LLM_ERROR = "LLM_ERROR", + TIMEOUT = "TIMEOUT", + + // Internal errors (500) + SDK_ERROR = "SDK_ERROR", + INTERNAL_ERROR = "INTERNAL_ERROR", +} + +/** + * Structured error response for Stagehand API. + */ +export interface StagehandErrorResponse { + error: string; + code: StagehandErrorCode; + operation?: string; + statusCode: number; +} + +// Error name to code mappings for user-actionable errors (400) +const USER_ERROR_MAP: Record = { + StagehandInvalidArgumentError: StagehandErrorCode.INVALID_ARGUMENT, + StagehandMissingArgumentError: StagehandErrorCode.MISSING_ARGUMENT, + MissingLLMConfigurationError: StagehandErrorCode.INVALID_MODEL, + UnsupportedModelError: StagehandErrorCode.INVALID_MODEL, + UnsupportedModelProviderError: StagehandErrorCode.INVALID_MODEL, + InvalidAISDKModelFormatError: StagehandErrorCode.INVALID_MODEL, + UnsupportedAISDKModelProviderError: StagehandErrorCode.INVALID_MODEL, + ExperimentalNotConfiguredError: StagehandErrorCode.EXPERIMENTAL_NOT_CONFIGURED, + ExperimentalApiConflictError: StagehandErrorCode.EXPERIMENTAL_NOT_CONFIGURED, + CuaModelRequiredError: StagehandErrorCode.INVALID_MODEL, + AI_APICallError: StagehandErrorCode.INVALID_MODEL, + APICallError: StagehandErrorCode.INVALID_MODEL, +}; + +// Operational error mappings (422) +interface OperationalErrorConfig { + code: StagehandErrorCode; + sanitize: (msg: string, op: string) => string; +} + +const OPERATIONAL_ERROR_MAP: Record = { + StagehandElementNotFoundError: { + code: StagehandErrorCode.ELEMENT_NOT_FOUND, + sanitize: (_msg, op) => `Could not find the requested element during ${op}`, + }, + XPathResolutionError: { + code: StagehandErrorCode.ELEMENT_NOT_FOUND, + sanitize: (_msg, op) => `XPath selector did not match any element during ${op}`, + }, + ElementNotVisibleError: { + code: StagehandErrorCode.ELEMENT_NOT_FOUND, + sanitize: (_msg, op) => `Element is not visible during ${op}`, + }, + StagehandClickError: { + code: StagehandErrorCode.ACTION_FAILED, + sanitize: (_msg, op) => `Click action failed during ${op}`, + }, + StagehandDomProcessError: { + code: StagehandErrorCode.ACTION_FAILED, + sanitize: (_msg, op) => `DOM processing failed during ${op}`, + }, + LLMResponseError: { + code: StagehandErrorCode.LLM_ERROR, + sanitize: (_msg, op) => `LLM processing failed during ${op}. Please try again.`, + }, + CreateChatCompletionResponseError: { + code: StagehandErrorCode.LLM_ERROR, + sanitize: (_msg, op) => `LLM request failed during ${op}. Please try again.`, + }, + CaptchaTimeoutError: { + code: StagehandErrorCode.TIMEOUT, + sanitize: (_msg, op) => `Captcha solving timed out during ${op}`, + }, + TimeoutError: { + code: StagehandErrorCode.TIMEOUT, + sanitize: (msg) => msg, // TimeoutError messages are already user-friendly + }, + ConnectionTimeoutError: { + code: StagehandErrorCode.TIMEOUT, + sanitize: (msg) => msg, + }, +}; + +const MAX_SANITIZED_MESSAGE_LENGTH = 100; + +/** + * Sanitizes error messages to remove potentially sensitive information. + */ +function sanitizeErrorMessage(message: string): string { + const sanitized = message + // Remove long alphanumeric strings (potential API keys/tokens) + .replace(/\b[a-zA-Z0-9_-]{32,}\b/g, "[redacted]") + // Remove common API key patterns (sk_, pk_, api_, etc.) + .replace(/\b(?:sk|pk|api|key|token|secret)_[a-zA-Z0-9]{16,}\b/gi, "[api-key]") + // Remove URLs + .replace(/https?:\/\/[^\s<>"']+/g, "[url]") + // Remove Unix-style file paths + .replace(/(?:^|\s)(\/[\w.-]+){2,}/g, " [path]") + // Remove Windows-style file paths + .replace(/[A-Z]:\\[\w\\.-]+/gi, "[path]") + // Remove emails + .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[email]") + // Remove IP addresses + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]"); + + const truncated = sanitized.substring(0, MAX_SANITIZED_MESSAGE_LENGTH); + return truncated + (sanitized.length > MAX_SANITIZED_MESSAGE_LENGTH ? "..." : ""); +} + +/** + * Maps Stagehand SDK errors to structured error responses with appropriate codes and messages. + */ +export function mapStagehandError(err: Error, operation: string): StagehandErrorResponse { + const errorName = err.constructor.name; + const { message } = err; + + // User-actionable errors (400) - pass through original message + const userErrorCode = USER_ERROR_MAP[errorName]; + if (userErrorCode) { + return { + error: message, + code: userErrorCode, + operation, + statusCode: 400, + }; + } + + // Schema validation errors - sanitize to avoid exposing raw data + if (errorName === "ZodSchemaValidationError") { + return { + error: `Schema validation failed during ${operation}`, + code: StagehandErrorCode.INVALID_SCHEMA, + operation, + statusCode: 400, + }; + } + + // Operational errors (422) - sanitize but provide useful context + const operationalConfig = OPERATIONAL_ERROR_MAP[errorName]; + if (operationalConfig) { + return { + error: operationalConfig.sanitize(message, operation), + code: operationalConfig.code, + operation, + statusCode: 422, + }; + } + + // Check for StagehandError base class errors that weren't explicitly mapped + if (errorName.startsWith("Stagehand")) { + return { + error: `${operation} operation failed: ${sanitizeErrorMessage(message)}`, + code: StagehandErrorCode.SDK_ERROR, + operation, + statusCode: 500, + }; + } + + // Unknown errors - hide details completely + return { + error: `${operation} operation failed unexpectedly`, + code: StagehandErrorCode.INTERNAL_ERROR, + operation, + statusCode: 500, + }; +} + /** * Generic HTTP request interface for streaming. * Structurally compatible with FastifyRequest from any version. @@ -41,9 +226,10 @@ export interface StreamingHandlerContext { export interface StreamingResponseOptions { sessionId: string; sessionStore: SessionStore; - requestContext: RequestContext; request: StreamingHttpRequest; reply: StreamingHttpReply; + /** The operation name for error reporting (e.g., "act", "extract", "observe") */ + operation: string; handler: (ctx: StreamingHandlerContext, data: T) => Promise; } @@ -59,14 +245,16 @@ function sendSSE(reply: StreamingHttpReply, data: object): void { } /** - * Creates a streaming response handler that sends events via SSE + * Creates a streaming response handler that sends events via SSE. + * Extracts RequestContext (modelApiKey, logger) from request headers automatically. + * Handles errors with proper status codes and sanitized messages. */ export async function createStreamingResponse({ sessionId, sessionStore, - requestContext, request, reply, + operation, handler, }: StreamingResponseOptions): Promise { // Check if streaming is requested @@ -100,9 +288,9 @@ export async function createStreamingResponse({ let handlerError: Error | null = null; try { - // Build request context with streaming logger if needed - const ctxWithLogger: RequestContext = { - ...requestContext, + // Build request context from headers, adding streaming logger if needed + const requestContext: RequestContext = { + modelApiKey: request.headers["x-model-api-key"] as string | undefined, logger: shouldStream ? async (message) => { sendSSE(reply, { @@ -117,7 +305,7 @@ export async function createStreamingResponse({ }; // Get or create the Stagehand instance from the session store - const stagehand = await sessionStore.getOrCreateStagehand(sessionId, ctxWithLogger); + const stagehand = await sessionStore.getOrCreateStagehand(sessionId, requestContext); if (shouldStream) { sendSSE(reply, { @@ -140,20 +328,22 @@ export async function createStreamingResponse({ // Handle error case if (handlerError) { - const errorMessage = handlerError.message || "An unexpected error occurred"; + const mappedError = mapStagehandError(handlerError, operation); if (shouldStream) { sendSSE(reply, { type: "system", data: { status: "error", - error: errorMessage, + error: mappedError.error, + code: mappedError.code, }, }); reply.raw.end(); } else { - reply.status(500).send({ - error: errorMessage, + reply.status(mappedError.statusCode).send({ + error: mappedError.error, + code: mappedError.code, }); } return; From 3c9494624173f73bd04f8626341626e913686600 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 16:53:18 -0800 Subject: [PATCH 28/30] properly type the stagehand obj --- packages/core/lib/v3/server/index.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index a6200c00e..a7041c0b6 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -426,8 +426,7 @@ export class StagehandServer { request, reply, operation: "act", - handler: async (handlerCtx, data) => { - const stagehand = handlerCtx.stagehand as any; + handler: async ({ stagehand }, data) => { const { frameId } = data; const page = frameId @@ -477,8 +476,7 @@ export class StagehandServer { request, reply, operation: "extract", - handler: async (handlerCtx, data) => { - const stagehand = handlerCtx.stagehand as any; + handler: async ({ stagehand }, data) => { const { frameId } = data; const page = frameId @@ -536,8 +534,7 @@ export class StagehandServer { request, reply, operation: "observe", - handler: async (handlerCtx, data) => { - const stagehand = handlerCtx.stagehand as any; + handler: async ({ stagehand }, data) => { const { frameId } = data; const page = frameId @@ -589,8 +586,7 @@ export class StagehandServer { request, reply, operation: "agentExecute", - handler: async (handlerCtx, data) => { - const stagehand = handlerCtx.stagehand as any; + handler: async ({ stagehand }, data) => { const { agentConfig, executeOptions, frameId } = data; const page = frameId @@ -628,8 +624,7 @@ export class StagehandServer { request, reply, operation: "navigate", - handler: async (handlerCtx, data) => { - const stagehand = handlerCtx.stagehand as any; + handler: async ({ stagehand }, data) => { const { url, options, frameId } = data; const page = frameId From d9941185154f1f36d671275fe7e10114c5489e45 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 3 Dec 2025 16:54:00 -0800 Subject: [PATCH 29/30] remove unused imports and codeformat --- packages/core/lib/v3/server/index.ts | 37 +++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts index a7041c0b6..2c11554cc 100644 --- a/packages/core/lib/v3/server/index.ts +++ b/packages/core/lib/v3/server/index.ts @@ -1,4 +1,4 @@ -import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import Fastify, { FastifyInstance } from "fastify"; import cors from "@fastify/cors"; import { serializerCompiler, @@ -17,7 +17,7 @@ import type { } from "../types/public"; import type { StagehandZodSchema } from "../zodCompat"; import { jsonSchemaToZod, type JsonSchema } from "../../utils"; -import type { SessionStore, RequestContext, CreateSessionParams } from "./SessionStore"; +import type { SessionStore, CreateSessionParams } from "./SessionStore"; import { InMemorySessionStore } from "./InMemorySessionStore"; import { createStreamingResponse, mapStagehandError } from "./stream"; @@ -86,7 +86,12 @@ export interface StagehandHttpReply { export * from "./events"; // Re-export SessionStore types -export type { SessionStore, RequestContext, CreateSessionParams, SessionStartResult } from "./SessionStore"; +export type { + SessionStore, + RequestContext, + CreateSessionParams, + SessionStartResult, +} from "./SessionStore"; export { InMemorySessionStore } from "./InMemorySessionStore"; // Re-export API schemas and types for consumers @@ -228,7 +233,9 @@ export class StagehandServer { private setupRoutes(): void { const app = this.app.withTypeProvider(); - const sessionValidationPreHandler = createSessionValidationPreHandler(this.sessionStore); + const sessionValidationPreHandler = createSessionValidationPreHandler( + this.sessionStore, + ); // Health check app.get("/health", async () => { @@ -383,8 +390,12 @@ export class StagehandServer { browserbaseSessionCreateParams: body.browserbaseSessionCreateParams, debugDom: body.debugDom, actTimeoutMs: body.actTimeoutMs, - browserbaseApiKey: request.headers["x-bb-api-key"] as string | undefined, - browserbaseProjectId: request.headers["x-bb-project-id"] as string | undefined, + browserbaseApiKey: request.headers["x-bb-api-key"] as + | string + | undefined, + browserbaseProjectId: request.headers["x-bb-project-id"] as + | string + | undefined, clientLanguage: request.headers["x-language"] as string | undefined, sdkVersion: request.headers["x-sdk-version"] as string | undefined, }; @@ -506,7 +517,11 @@ export class StagehandServer { const zodSchema = jsonSchemaToZod( data.schema as unknown as JsonSchema, ) as StagehandZodSchema; - result = await stagehand.extract(data.instruction, zodSchema, safeOptions); + result = await stagehand.extract( + data.instruction, + zodSchema, + safeOptions, + ); } else { result = await stagehand.extract(data.instruction, safeOptions); } @@ -602,7 +617,9 @@ export class StagehandServer { page, }; - const result: AgentResult = await stagehand.agent(agentConfig).execute(fullExecuteOptions); + const result: AgentResult = await stagehand + .agent(agentConfig) + .execute(fullExecuteOptions); return { result }; }, @@ -678,7 +695,9 @@ export class StagehandServer { host: this.host, }); this.isListening = true; - console.log(`Stagehand server listening on http://${this.host}:${listenPort}`); + console.log( + `Stagehand server listening on http://${this.host}:${listenPort}`, + ); } catch (error) { console.error("Failed to start server:", error); throw error; From 569ac1e6b3d339fcb0e5b5d8fb8cd852ec013a82 Mon Sep 17 00:00:00 2001 From: monadoid Date: Wed, 3 Dec 2025 17:01:52 -0800 Subject: [PATCH 30/30] Fixed env vars --- packages/core/tests/global-setup.stagehand.ts | 17 +++-- .../tests/integration/integration.test.ts | 3 + .../integration/support/stagehandClient.ts | 68 +++++++++++------- packages/core/tests/support/testEnv.ts | 69 +++++++++++++++++++ 4 files changed, 126 insertions(+), 31 deletions(-) create mode 100644 packages/core/tests/support/testEnv.ts diff --git a/packages/core/tests/global-setup.stagehand.ts b/packages/core/tests/global-setup.stagehand.ts index ca51d44ef..950db6b4c 100644 --- a/packages/core/tests/global-setup.stagehand.ts +++ b/packages/core/tests/global-setup.stagehand.ts @@ -1,4 +1,8 @@ import type { ProvidedContext } from "vitest"; +import { + ensureTestEnvLoaded, + requireStagehandEnvVar, +} from "./support/testEnv"; const DEFAULT_REMOTE_URL = "https://api.stagehand.browserbase.com/v1"; const DEFAULT_LOCAL_PORT = 43123; @@ -15,19 +19,17 @@ let localServer: { close: () => Promise; getUrl: () => string } | null = let localServerStagehand: { close: () => Promise } | null = null; async function startLocalServer() { + ensureTestEnvLoaded(); const { Stagehand } = await import("../dist/index.js"); const host = process.env.STAGEHAND_LOCAL_HOST ?? "127.0.0.1"; const port = Number(process.env.STAGEHAND_LOCAL_PORT ?? DEFAULT_LOCAL_PORT); const serverModel = process.env.STAGEHAND_SERVER_MODEL ?? "openai/gpt-4o-mini"; - const serverApiKey = process.env.OPENAI_API_KEY; - - if (!serverApiKey) { - throw new Error( - "Missing OPENAI_API_KEY for local Stagehand server.", - ); - } + const serverApiKey = requireStagehandEnvVar("OPENAI_API_KEY", { + scope: "server", + consumer: "local Stagehand server", + }); const stagehand = new Stagehand({ env: "LOCAL", @@ -57,6 +59,7 @@ async function startLocalServer() { } export async function setup(ctx: StagehandGlobalSetupContext) { + ensureTestEnvLoaded(); const target = (process.env.STAGEHAND_TEST_TARGET ?? "local").toLowerCase(); const normalizedTarget = target === "local" ? "local" : "remote"; ctx.provide("STAGEHAND_TEST_TARGET", normalizedTarget); diff --git a/packages/core/tests/integration/integration.test.ts b/packages/core/tests/integration/integration.test.ts index f8314cfed..ff4b30a78 100644 --- a/packages/core/tests/integration/integration.test.ts +++ b/packages/core/tests/integration/integration.test.ts @@ -6,6 +6,9 @@ import { getMissingClientEnvVars, resolveTestTarget, } from "./support/stagehandClient"; +import { ensureTestEnvLoaded } from "../support/testEnv"; + +ensureTestEnvLoaded(); const testSite = process.env.STAGEHAND_EVAL_URL ?? diff --git a/packages/core/tests/integration/support/stagehandClient.ts b/packages/core/tests/integration/support/stagehandClient.ts index 76c6fa913..d9da363f0 100644 --- a/packages/core/tests/integration/support/stagehandClient.ts +++ b/packages/core/tests/integration/support/stagehandClient.ts @@ -1,5 +1,12 @@ import { inject } from "vitest"; import * as Stagehand from "../../../dist/index.js"; +import { + ensureTestEnvLoaded, + getStagehandEnvVar, + requireStagehandEnvVar, +} from "../../support/testEnv"; + +ensureTestEnvLoaded(); type TestTarget = "remote" | "local"; @@ -24,7 +31,7 @@ export function getMissingClientEnvVars(target: TestTarget): string[] { const required = target === "remote" ? REMOTE_REQUIRED_VARS : LOCAL_REQUIRED_VARS; return required.filter((name) => { - const value = process.env[name]; + const value = getStagehandEnvVar(name, { scope: "client" }); return !value || value.length === 0; }); } @@ -37,34 +44,47 @@ export function createStagehandHarness(target?: TestTarget) { } const normalizedBaseUrl = providedBaseUrl.endsWith("/v1") - ? providedBaseUrl - : `${providedBaseUrl.replace(/\/$/, "")}/v1`; + ? providedBaseUrl + : `${providedBaseUrl.replace(/\/$/, "")}/v1`; process.env.STAGEHAND_API_URL = normalizedBaseUrl; - const stagehandOptions: Record = {}; + const clientApiKey = requireStagehandEnvVar("OPENAI_API_KEY", { + scope: "client", + consumer: `${activeTarget} Stagehand client`, + }); - if (activeTarget === "local") { - const clientApiKey = process.env.OPENAI_API_KEY; - if (!clientApiKey) { - throw new Error( - "Missing OPENAI_API_KEY for local client.", - ); - } + const stagehandOptions: Stagehand.V3Options = { + env: activeTarget === "local" ? "LOCAL" : "BROWSERBASE", + verbose: 0, + experimental: false, + logInferenceToFile: false, + model: { + modelName: "openai/gpt-5-mini", + apiKey: clientApiKey, + }, + }; - const stagehand = new Stagehand.Stagehand({ - env: "BROWSERBASE", - verbose: 0, - model: { - modelName: "openai/gpt-5-mini", - apiKey: clientApiKey, - }, - experimental: false, - logInferenceToFile: false, + if (activeTarget === "local") { + stagehandOptions.localBrowserLaunchOptions = { + headless: process.env.STAGEHAND_CLIENT_HEADLESS !== "false", + }; + } else { + stagehandOptions.apiKey = requireStagehandEnvVar("BROWSERBASE_API_KEY", { + scope: "client", + consumer: "remote Stagehand client", }); + stagehandOptions.projectId = requireStagehandEnvVar( + "BROWSERBASE_PROJECT_ID", + { + scope: "client", + consumer: "remote Stagehand client", + }, + ); + } - const apiRootUrl = normalizedBaseUrl.replace(/\/v1\/?$/, ""); + const stagehand = new Stagehand.Stagehand(stagehandOptions); + const apiRootUrl = normalizedBaseUrl.replace(/\/v1\/?$/, ""); - return {stagehand, apiRootUrl, target: activeTarget}; - } -} \ No newline at end of file + return { stagehand, apiRootUrl, target: activeTarget }; +} diff --git a/packages/core/tests/support/testEnv.ts b/packages/core/tests/support/testEnv.ts new file mode 100644 index 000000000..c0c2977d4 --- /dev/null +++ b/packages/core/tests/support/testEnv.ts @@ -0,0 +1,69 @@ +import { config as loadDotenv } from "dotenv"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// @ts-expect-error - meta works in vitest +const supportDir = dirname(fileURLToPath(import.meta.url)); +const envFilePath = resolve(supportDir, "../.env"); + +type StagehandEnvScope = "client" | "server"; + +let envLoaded = false; + +export function ensureTestEnvLoaded() { + if (envLoaded) return; + envLoaded = true; + + if (!existsSync(envFilePath)) return; + + const result = loadDotenv({ path: envFilePath }); + if (result.error) { + throw result.error; + } +} + +function scopedEnvKey(name: string, scope?: StagehandEnvScope) { + if (!scope) return undefined; + return `STAGEHAND_${scope.toUpperCase()}_${name}`; +} + +export function getStagehandEnvVar( + name: string, + options?: { scope?: StagehandEnvScope }, +) { + ensureTestEnvLoaded(); + const scopedKey = scopedEnvKey(name, options?.scope); + + const scopedValue = scopedKey ? process.env[scopedKey] : undefined; + if (scopedValue && scopedValue.length > 0) { + return scopedValue; + } + + const defaultValue = process.env[name]; + if (defaultValue && defaultValue.length > 0) { + return defaultValue; + } + + return undefined; +} + +export function requireStagehandEnvVar( + name: string, + options?: { scope?: StagehandEnvScope; consumer?: string }, +) { + const value = getStagehandEnvVar(name, options); + if (value) return value; + + const scopedKey = scopedEnvKey(name, options?.scope); + const expected = scopedKey ? `${scopedKey} or ${name}` : name; + const consumerSuffix = options?.consumer + ? ` for ${options.consumer}` + : ""; + + throw new Error( + `Missing ${name}${consumerSuffix}. Provide ${expected} via tests/.env or your shell environment.`, + ); +} + +ensureTestEnvLoaded();