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/lib/inference.ts b/packages/core/lib/inference.ts index 9b843e043..978526757 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; @@ -424,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/api.ts b/packages/core/lib/v3/api.ts index 13bc3892d..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, @@ -54,17 +54,42 @@ 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; + const resolvedBaseUrl = + baseUrl || + process.env.STAGEHAND_API_URL || + "https://api.stagehand.browserbase.com/v1"; + this.baseUrl = resolvedBaseUrl; this.logger = logger; + + // 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. " + + "Set STAGEHAND_API_URL or 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); } @@ -78,7 +103,7 @@ export class StagehandAPIClient { selfHeal, browserbaseSessionCreateParams, browserbaseSessionID, - }: StartSessionParams): Promise { + }: StartSessionParams): Promise { if (!modelApiKey) { throw new StagehandAPIError("modelApiKey is required"); } @@ -121,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); @@ -477,30 +502,34 @@ 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) + // 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; + } + 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/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/index.ts b/packages/core/lib/v3/index.ts index 8e102cba7..168d0a9f9 100644 --- a/packages/core/lib/v3/index.ts +++ b/packages/core/lib/v3/index.ts @@ -2,6 +2,21 @@ export { V3 } from "./v3"; export { V3 as Stagehand } from "./v3"; export * from "./types/public"; + +// Event bus - shared by library and server +export { StagehandEventBus, createEventBus } from "./eventBus"; +export * from "./server/events"; + +// Server exports for P2P functionality +export { StagehandServer } from "./server"; +export type { StagehandServerOptions } from "./server"; +export { 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/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/InMemorySessionStore.ts b/packages/core/lib/v3/server/InMemorySessionStore.ts new file mode 100644 index 000000000..2f426ddef --- /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, + SessionStartResult, +} 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..fdf45b54e --- /dev/null +++ b/packages/core/lib/v3/server/SessionStore.ts @@ -0,0 +1,168 @@ +import type { LogLine } from "../types/public"; +import type { V3 } from "../v3"; +import type { SessionStartResult } from "./schemas"; + +export type { SessionStartResult }; + +/** + * 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; +} + + +/** + * 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/events.ts b/packages/core/lib/v3/server/events.ts new file mode 100644 index 000000000..2f3c23f95 --- /dev/null +++ b/packages/core/lib/v3/server/events.ts @@ -0,0 +1,90 @@ +/** + * Base event interface - all events extend this + */ +export interface StagehandServerEvent { + timestamp: Date; + sessionId?: string; + 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"; + 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 = + | StagehandLLMRequestEvent + | StagehandLLMResponseEvent + | StagehandLLMErrorEvent; + +// Type-safe event emitter interface +export interface StagehandServerEventMap { + StagehandLLMRequest: StagehandLLMRequestEvent; + StagehandLLMResponse: StagehandLLMResponseEvent; + StagehandLLMError: StagehandLLMErrorEvent; +} diff --git a/packages/core/lib/v3/server/index.ts b/packages/core/lib/v3/server/index.ts new file mode 100644 index 000000000..2c11554cc --- /dev/null +++ b/packages/core/lib/v3/server/index.ts @@ -0,0 +1,727 @@ +import Fastify, { FastifyInstance } from "fastify"; +import cors from "@fastify/cors"; +import { + serializerCompiler, + validatorCompiler, + type ZodTypeProvider, +} from "fastify-type-provider-zod"; +import type { + ActOptions, + ActResult, + ExtractResult, + ExtractOptions, + ObserveOptions, + Action, + AgentResult, + ModelConfiguration, +} from "../types/public"; +import type { StagehandZodSchema } from "../zodCompat"; +import { jsonSchemaToZod, type JsonSchema } from "../../utils"; +import type { SessionStore, CreateSessionParams } from "./SessionStore"; +import { InMemorySessionStore } from "./InMemorySessionStore"; +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, + ExtractRequestSchema, + ExtractResponseSchema, + ObserveRequestSchema, + ObserveResponseSchema, + AgentExecuteRequestSchema, + AgentExecuteResponseSchema, + NavigateRequestSchema, + NavigateResponseSchema, + SessionStartRequestSchema, + SessionStartResponseSchema, + SessionIdParamsSchema, + SessionEndRequestSchema, + SessionEndResponseSchema, + type SessionStartRequest, + type SessionEndRequest, + type ActRequest, + type ExtractRequest, + type ObserveRequest, + type AgentExecuteRequest, + type NavigateRequest, + type SessionIdParams, +} from "./schemas"; + +// ============================================================================= +// Generic HTTP interfaces for cross-version Fastify compatibility +// ============================================================================= + +/** + * 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 { + headers: Record; + body: TBody; + params: TParams; +} + +/** + * 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 (only LLM events are actually used) +export * from "./events"; + +// Re-export SessionStore types +export type { + SessionStore, + RequestContext, + CreateSessionParams, + SessionStartResult, +} from "./SessionStore"; +export { InMemorySessionStore } from "./InMemorySessionStore"; + +// Re-export API schemas and types for consumers +export * from "./schemas"; + +// ============================================================================= +// Standalone PreHandler Factory Functions +// ============================================================================= + +/** + * Validates the session ID param exists and session is found in SessionStore. + * + * 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 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; + } + + const hasSession = await sessionStore.hasSession(id); + if (!hasSession) { + reply.status(404).send({ + error: "Session not found", + }); + return false; + } + + return true; +} + +/** + * Creates a preHandler that validates the session ID param exists and session is found in SessionStore. + * + * 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, +): ( + 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); + }; +} + +export interface StagehandServerOptions { + port?: number; + host?: string; + /** + * 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; +} + +/** + * StagehandServer - Embedded API server for peer-to-peer Stagehand communication + * + * This server implements the same API as the cloud Stagehand API, allowing + * remote Stagehand instances to connect and execute actions on this machine. + * + * Uses a SessionStore interface for session management, allowing cloud environments + * to provide database-backed implementations for stateless pod architectures. + */ +export class StagehandServer { + private app: FastifyInstance; + private sessionStore: SessionStore; + 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.sessionStore = options.sessionStore ?? new InMemorySessionStore(); + this.app = Fastify({ + logger: false, + }); + + // Set up Zod type provider for automatic request/response validation + this.app.setValidatorCompiler(validatorCompiler); + this.app.setSerializerCompiler(serializerCompiler); + + this.setupMiddleware(); + this.setupRoutes(); + } + + /** + * Get the session store instance + */ + getSessionStore(): SessionStore { + return this.sessionStore; + } + + private setupMiddleware(): void { + this.app.register(cors, { + origin: "*", + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: "*", + credentials: true, + }); + } + + private setupRoutes(): void { + const app = this.app.withTypeProvider(); + const sessionValidationPreHandler = createSessionValidationPreHandler( + this.sessionStore, + ); + + // Health check + app.get("/health", async () => { + return { status: "ok" }; + }); + + // Start session + app.post( + "/v1/sessions/start", + { + schema: { + body: SessionStartRequestSchema, + response: { + 200: SessionStartResponseSchema, + }, + }, + }, + async (request, reply) => { + return this.handleStartSession(request, reply); + }, + ); + + // Act endpoint + app.post( + "/v1/sessions/:id/act", + { + schema: { + params: SessionIdParamsSchema, + body: ActRequestSchema, + response: { + 200: ActResponseSchema, + }, + }, + preHandler: [sessionValidationPreHandler], + }, + async (request, reply) => { + return this.handleAct(request, reply); + }, + ); + + // Extract endpoint + app.post( + "/v1/sessions/:id/extract", + { + schema: { + params: SessionIdParamsSchema, + body: ExtractRequestSchema, + response: { + 200: ExtractResponseSchema, + }, + }, + preHandler: [sessionValidationPreHandler], + }, + async (request, reply) => { + return this.handleExtract(request, reply); + }, + ); + + // Observe endpoint + app.post( + "/v1/sessions/:id/observe", + { + schema: { + params: SessionIdParamsSchema, + body: ObserveRequestSchema, + response: { + 200: ObserveResponseSchema, + }, + }, + preHandler: [sessionValidationPreHandler], + }, + async (request, reply) => { + return this.handleObserve(request, reply); + }, + ); + + // Agent execute endpoint + app.post( + "/v1/sessions/:id/agentExecute", + { + schema: { + params: SessionIdParamsSchema, + body: AgentExecuteRequestSchema, + response: { + 200: AgentExecuteResponseSchema, + }, + }, + preHandler: [sessionValidationPreHandler], + }, + async (request, reply) => { + return this.handleAgentExecute(request, reply); + }, + ); + + // Navigate endpoint + app.post( + "/v1/sessions/:id/navigate", + { + schema: { + params: SessionIdParamsSchema, + body: NavigateRequestSchema, + response: { + 200: NavigateResponseSchema, + }, + }, + preHandler: [sessionValidationPreHandler], + }, + async (request, reply) => { + return this.handleNavigate(request, reply); + }, + ); + + // End session + app.post( + "/v1/sessions/:id/end", + { + schema: { + params: SessionIdParamsSchema, + body: SessionEndRequestSchema, + response: { + 200: SessionEndResponseSchema, + }, + }, + preHandler: [sessionValidationPreHandler], + }, + async (request, reply) => { + return this.handleEndSession(request, reply); + }, + ); + } + + /** + * Handle /sessions/start - Create new session + * Body is pre-validated by Fastify using SessionStartRequestSchema + */ + async handleStartSession( + request: StagehandHttpRequest, + reply: StagehandHttpReply, + ): Promise { + try { + const { body } = request; + + const createParams: CreateSessionParams = { + modelName: body.modelName, + verbose: body.verbose, + 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, + 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, + }; + + const result = await this.sessionStore.startSession(createParams); + + reply.status(200).send({ + success: true, + data: { + sessionId: result.sessionId, + available: result.available, + }, + }); + } catch (error) { + const mappedError = mapStagehandError( + error instanceof Error ? error : new Error("Failed to create session"), + "startSession", + ); + reply.status(mappedError.statusCode).send({ + success: false, + error: mappedError.error, + code: mappedError.code, + }); + } + } + + /** + * 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, + reply: StagehandHttpReply, + ): Promise { + await createStreamingResponse({ + sessionId: request.params.id, + sessionStore: this.sessionStore, + request, + reply, + operation: "act", + handler: async ({ stagehand }, data) => { + 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 ExtractRequestSchema + * Session is pre-validated by sessionValidationPreHandler + */ + async handleExtract( + request: StagehandHttpRequest, + reply: StagehandHttpReply, + ): Promise { + await createStreamingResponse({ + sessionId: request.params.id, + sessionStore: this.sessionStore, + request, + reply, + operation: "extract", + handler: async ({ stagehand }, data) => { + 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); + } + } else { + result = await stagehand.extract(safeOptions); + } + + return { result }; + }, + }); + } + + /** + * 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, + reply: StagehandHttpReply, + ): Promise { + await createStreamingResponse({ + sessionId: request.params.id, + sessionStore: this.sessionStore, + request, + reply, + operation: "observe", + handler: async ({ stagehand }, data) => { + 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, + }; + + let result: Action[]; + + if (data.instruction) { + result = await stagehand.observe(data.instruction, safeOptions); + } else { + result = await stagehand.observe(safeOptions); + } + + return { result }; + }, + }); + } + + /** + * 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, + reply: StagehandHttpReply, + ): Promise { + await createStreamingResponse({ + sessionId: request.params.id, + sessionStore: this.sessionStore, + request, + reply, + operation: "agentExecute", + handler: async ({ stagehand }, data) => { + 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 }; + }, + }); + } + + /** + * 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, + reply: StagehandHttpReply, + ): Promise { + await createStreamingResponse({ + sessionId: request.params.id, + sessionStore: this.sessionStore, + request, + reply, + operation: "navigate", + handler: async ({ stagehand }, data) => { + const { url, options, frameId } = data; + + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); + + if (!page) { + throw new Error("Page not found"); + } + + const response = await page.goto(url, options); + + return { result: response }; + }, + }); + } + + /** + * 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, + reply: StagehandHttpReply, + ): Promise { + try { + await this.sessionStore.endSession(request.params.id); + reply.status(200).send({ success: true }); + } catch (error) { + 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, + }); + } + } + + /** + * 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.sessionStore.destroy(); + } + + /** + * Get server URL + */ + getUrl(): string { + if (!this.isListening) { + throw new Error("Server is not listening"); + } + return `http://${this.host}:${this.port}`; + } +} diff --git a/packages/core/lib/v3/server/schemas.ts b/packages/core/lib/v3/server/schemas.ts new file mode 100644 index 000000000..e0e1f9ddc --- /dev/null +++ b/packages/core/lib/v3/server/schemas.ts @@ -0,0 +1,272 @@ +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) + */ + +// ============================================================================= +// Common Schemas +// ============================================================================= + +/** Standard API success response wrapper */ +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({ + provider: z.string().optional(), + model: z.string().optional(), + apiKey: z.string().optional(), + baseURL: z.string().url().optional(), +}); + +/** 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(), +}); + +/** 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 Start +// ============================================================================= + +/** POST /v1/sessions/start - Request body */ +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(), + 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(), +}); + +/** Internal result from SessionStore.startSession() - sessionId always present */ +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 SessionStartResponseDataSchema = z.object({ + sessionId: z.string().nullable(), + available: z.boolean(), +}); + +/** POST /v1/sessions/start - Full HTTP response */ +export const SessionStartResponseSchema = SuccessResponseSchema(SessionStartResponseDataSchema); + +// ============================================================================= +// 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), +}); + +// ============================================================================= +// Act +// ============================================================================= + +/** POST /v1/sessions/:id/act - Request body */ +export const ActRequestSchema = 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: ModelConfigSchema.optional(), + variables: z.record(z.string(), z.string()).optional(), + timeout: z.number().optional(), + }) + .optional(), + frameId: z.string().optional(), +}); + +/** 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(), + timeout: z.number().optional(), + selector: z.string().optional(), + }) + .optional(), + frameId: z.string().optional(), +}); + +/** 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(), + timeout: z.number().optional(), + selector: z.string().optional(), + }) + .optional(), + frameId: z.string().optional(), +}); + +/** 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 + .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(), +}); + +/** 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({ + waitUntil: z.enum(["load", "domcontentloaded", "networkidle"]).optional(), + }) + .optional(), + frameId: z.string().optional(), +}); + +/** POST /v1/sessions/:id/navigate - Response */ +export const NavigateResponseSchema = z.object({ + url: z.string().optional(), + status: z.number().optional(), +}); + +// ============================================================================= +// Inferred Types +// ============================================================================= + +// 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 SessionEndRequest = 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; diff --git a/packages/core/lib/v3/server/stream.ts b/packages/core/lib/v3/server/stream.ts new file mode 100644 index 000000000..16b7e0204 --- /dev/null +++ b/packages/core/lib/v3/server/stream.ts @@ -0,0 +1,367 @@ +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. + */ +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; + on?(event: string, handler: (...args: unknown[]) => void): unknown; + }; + sent?: boolean; + hijack?(): void; +} + +export interface StreamingHandlerResult { + result: unknown; +} + +export interface StreamingHandlerContext { + stagehand: V3; + sessionId: string; + request: StreamingHttpRequest; +} + +export interface StreamingResponseOptions { + sessionId: string; + sessionStore: SessionStore; + request: StreamingHttpRequest; + reply: StreamingHttpReply; + /** The operation name for error reporting (e.g., "act", "extract", "observe") */ + operation: string; + handler: (ctx: StreamingHandlerContext, data: T) => Promise; +} + +/** + * Sends an SSE (Server-Sent Events) message to the client + */ +function sendSSE(reply: StreamingHttpReply, 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. + * Extracts RequestContext (modelApiKey, logger) from request headers automatically. + * Handles errors with proper status codes and sanitized messages. + */ +export async function createStreamingResponse({ + sessionId, + sessionStore, + request, + reply, + operation, + 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 { + // 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, { + type: "log", + data: { + status: "running", + message, + }, + }); + } + : undefined, + }; + + // Get or create the Stagehand instance from the session store + const stagehand = await sessionStore.getOrCreateStagehand(sessionId, requestContext); + + 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 mappedError = mapStagehandError(handlerError, operation); + + if (shouldStream) { + sendSSE(reply, { + type: "system", + data: { + status: "error", + error: mappedError.error, + code: mappedError.code, + }, + }); + reply.raw.end(); + } else { + reply.status(mappedError.statusCode).send({ + error: mappedError.error, + code: mappedError.code, + }); + } + 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/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/private/api.ts b/packages/core/lib/v3/types/private/api.ts index 230584d28..e24d7bfd8 100644 --- a/packages/core/lib/v3/types/private/api.ts +++ b/packages/core/lib/v3/types/private/api.ts @@ -5,13 +5,15 @@ import { ExtractOptions, LogLine, ObserveOptions, + V3Options, } from "../public"; 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; } @@ -21,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, @@ -33,12 +42,17 @@ export interface StartSessionParams { > & { 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 { - sessionId: string; - available?: boolean; -} +// Re-export SessionStartResult from schemas (defined as Zod schema) +export type { SessionStartResult } from "../../server/schemas"; export interface SuccessResponse { success: true; diff --git a/packages/core/lib/v3/types/public/options.ts b/packages/core/lib/v3/types/public/options.ts index 3e5f45e43..219bf3480 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"; @@ -85,5 +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 71d84d17b..1b263bc46 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; @@ -159,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; @@ -171,6 +172,7 @@ export class V3 { private actCache: ActCache; private agentCache: AgentCache; private apiClient: StagehandAPIClient | null = null; + private llmEventHandlerCleanup: (() => void) | null = null; public stagehandMetrics: StagehandMetrics = { actPromptTokens: 0, @@ -201,6 +203,9 @@ export class V3 { }; constructor(opts: V3Options) { + // Create event bus first - both library and server will use this + this.eventBus = opts.eventBus || createEventBus(); + V3._installProcessGuards(); this.externalLogger = opts.logger; this.verbose = opts.verbose ?? 1; @@ -244,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) @@ -252,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; @@ -305,6 +308,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); @@ -588,6 +599,7 @@ export class V3 { this.modelName, this.modelClientOptions, (model) => this.resolveLlmClient(model), + this.eventBus, this.opts.systemPrompt ?? "", this.logInferenceToFile, this.opts.selfHeal ?? true, @@ -614,6 +626,7 @@ export class V3 { this.modelName, this.modelClientOptions, (model) => this.resolveLlmClient(model), + this.eventBus, this.opts.systemPrompt ?? "", this.logInferenceToFile, this.experimental, @@ -639,6 +652,7 @@ export class V3 { this.modelName, this.modelClientOptions, (model) => this.resolveLlmClient(model), + this.eventBus, this.opts.systemPrompt ?? "", this.logInferenceToFile, this.experimental, @@ -659,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. @@ -789,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 @@ -961,6 +928,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 +958,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 +1026,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 +1081,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 +1114,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 +1135,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 +1155,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 +1165,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 +1196,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 +1211,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") { @@ -1297,6 +1289,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; @@ -1817,6 +1819,31 @@ 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); + } + } function isObserveResult(v: unknown): v is Action { diff --git a/packages/core/openapi.yaml b/packages/core/openapi.yaml new file mode 100644 index 000000000..378218891 --- /dev/null +++ b/packages/core/openapi.yaml @@ -0,0 +1,711 @@ +openapi: 3.0.3 +info: + title: Stagehand P2P Server API + description: | + HTTP API for remote Stagehand browser automation. This API allows clients to + connect to a Stagehand server and execute browser automation tasks remotely. + + All endpoints except /sessions/start require an active session ID. + Responses are streamed using Server-Sent Events (SSE) when the + `x-stream-response: true` header is provided. + version: 3.0.0 + contact: + name: Browserbase + url: https://browserbase.com + +servers: + - url: http://localhost:3000/v1 + description: Local P2P server + - url: https://api.stagehand.browserbase.com/v1 + description: Cloud API (for reference) + +paths: + /sessions/start: + post: + summary: Create a new browser session + description: | + Initializes a new Stagehand session with a browser instance. + Returns a session ID that must be used for all subsequent requests. + operationId: createSession + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SessionConfig' + examples: + local: + summary: Local browser session + value: + env: LOCAL + verbose: 1 + browserbase: + summary: Browserbase session + value: + env: BROWSERBASE + apiKey: bb_api_key_123 + projectId: proj_123 + verbose: 1 + responses: + '200': + description: Session created successfully + content: + application/json: + schema: + type: object + required: + - sessionId + - available + properties: + sessionId: + type: string + format: uuid + description: Unique identifier for the session + available: + type: boolean + description: Whether the session is ready to use + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/act: + post: + summary: Execute an action on the page + description: | + Performs a browser action based on natural language instruction or + a specific action object returned by observe(). + operationId: act + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActRequest' + examples: + stringInstruction: + summary: Natural language instruction + value: + input: "click the sign in button" + actionObject: + summary: Execute observed action + value: + input: + selector: "#login-btn" + description: "Sign in button" + method: "click" + arguments: [] + responses: + '200': + description: Action executed successfully + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/ActResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/extract: + post: + summary: Extract structured data from the page + description: | + Extracts data from the current page using natural language instructions + and optional JSON schema for structured output. + operationId: extract + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExtractRequest' + examples: + simple: + summary: Simple extraction + value: + instruction: "extract the page title" + withSchema: + summary: Structured extraction + value: + instruction: "extract all product listings" + schema: + type: object + properties: + products: + type: array + items: + type: object + properties: + name: + type: string + price: + type: string + responses: + '200': + description: Data extracted successfully + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/ExtractResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/observe: + post: + summary: Observe possible actions on the page + description: | + Returns a list of candidate actions that can be performed on the page, + optionally filtered by natural language instruction. + operationId: observe + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ObserveRequest' + examples: + allActions: + summary: Observe all actions + value: {} + filtered: + summary: Observe specific actions + value: + instruction: "find all buttons" + responses: + '200': + description: Actions observed successfully + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/ObserveResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/agentExecute: + post: + summary: Execute a multi-step agent task + description: | + Runs an autonomous agent that can perform multiple actions to + complete a complex task. + operationId: agentExecute + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentExecuteRequest' + examples: + basic: + summary: Basic agent task + value: + agentConfig: + model: "openai/gpt-4o" + executeOptions: + instruction: "Find and click the first product" + maxSteps: 10 + responses: + '200': + description: Agent task completed + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/AgentResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/navigate: + post: + summary: Navigate to a URL + description: | + Navigates the browser to the specified URL and waits for page load. + operationId: navigate + parameters: + - $ref: '#/components/parameters/SessionId' + - $ref: '#/components/parameters/StreamResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NavigateRequest' + examples: + simple: + summary: Simple navigation + value: + url: "https://example.com" + withOptions: + summary: Navigation with options + value: + url: "https://example.com" + options: + waitUntil: "networkidle" + responses: + '200': + description: Navigation completed + content: + text/event-stream: + schema: + $ref: '#/components/schemas/SSEResponse' + application/json: + schema: + $ref: '#/components/schemas/NavigateResult' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/SessionNotFound' + '500': + $ref: '#/components/responses/InternalError' + + /sessions/{sessionId}/end: + post: + summary: End the session and cleanup resources + description: | + Closes the browser and cleans up all resources associated with the session. + operationId: endSession + parameters: + - $ref: '#/components/parameters/SessionId' + responses: + '200': + description: Session ended successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + '500': + $ref: '#/components/responses/InternalError' + +components: + parameters: + SessionId: + name: sessionId + in: path + required: true + description: The session ID returned by /sessions/start + schema: + type: string + format: uuid + + StreamResponse: + name: x-stream-response + in: header + description: Enable Server-Sent Events streaming for real-time logs + schema: + type: string + enum: ["true", "false"] + default: "true" + + schemas: + SessionConfig: + type: object + required: + - env + properties: + env: + type: string + enum: [LOCAL, BROWSERBASE] + description: Environment to run the browser in + verbose: + type: integer + minimum: 0 + maximum: 2 + default: 0 + description: Logging verbosity level + model: + type: string + description: AI model to use for actions + example: "openai/gpt-4o" + apiKey: + type: string + description: API key for Browserbase (required when env=BROWSERBASE) + projectId: + type: string + description: Project ID for Browserbase (required when env=BROWSERBASE) + systemPrompt: + type: string + description: Custom system prompt for AI actions + domSettleTimeout: + type: integer + description: Timeout in ms to wait for DOM to settle + selfHeal: + type: boolean + description: Enable self-healing for failed actions + localBrowserLaunchOptions: + type: object + description: Options for local browser launch + properties: + headless: + type: boolean + default: true + + ActRequest: + type: object + required: + - input + properties: + input: + oneOf: + - type: string + description: Natural language instruction + - $ref: '#/components/schemas/Action' + options: + $ref: '#/components/schemas/ActionOptions' + frameId: + type: string + description: Frame ID to act on (optional) + + Action: + type: object + required: + - selector + - description + - method + - arguments + properties: + selector: + type: string + description: CSS or XPath selector for the element + description: + type: string + description: Human-readable description of the action + backendNodeId: + type: integer + description: CDP backend node ID + method: + type: string + description: Method to execute (e.g., "click", "fill") + arguments: + type: array + items: + type: string + description: Arguments for the method + + ActionOptions: + type: object + properties: + model: + $ref: '#/components/schemas/ModelConfig' + variables: + type: object + additionalProperties: + type: string + description: Template variables for instruction + timeout: + type: integer + description: Timeout in milliseconds + + ModelConfig: + type: object + properties: + provider: + type: string + enum: [openai, anthropic, google] + model: + type: string + description: Model name + apiKey: + type: string + description: API key for the model provider + baseURL: + type: string + format: uri + description: Custom base URL for API + + ExtractRequest: + type: object + properties: + instruction: + type: string + description: Natural language instruction for extraction + schema: + type: object + description: JSON Schema for structured output + additionalProperties: true + options: + type: object + properties: + model: + $ref: '#/components/schemas/ModelConfig' + timeout: + type: integer + selector: + type: string + description: Extract only from elements matching this selector + frameId: + type: string + description: Frame ID to extract from + + ObserveRequest: + type: object + properties: + instruction: + type: string + description: Natural language instruction to filter actions + options: + type: object + properties: + model: + $ref: '#/components/schemas/ModelConfig' + timeout: + type: integer + selector: + type: string + description: Observe only elements matching this selector + frameId: + type: string + description: Frame ID to observe + + AgentExecuteRequest: + type: object + required: + - agentConfig + - executeOptions + properties: + agentConfig: + type: object + properties: + provider: + type: string + enum: [openai, anthropic, google] + model: + oneOf: + - type: string + - $ref: '#/components/schemas/ModelConfig' + systemPrompt: + type: string + cua: + type: boolean + description: Enable Computer Use Agent mode + executeOptions: + type: object + required: + - instruction + properties: + instruction: + type: string + description: Task for the agent to complete + maxSteps: + type: integer + default: 20 + description: Maximum number of steps the agent can take + highlightCursor: + type: boolean + description: Visually highlight the cursor during actions + frameId: + type: string + + NavigateRequest: + type: object + required: + - url + properties: + url: + type: string + format: uri + description: URL to navigate to + options: + type: object + properties: + waitUntil: + type: string + enum: [load, domcontentloaded, networkidle] + default: load + description: When to consider navigation complete + frameId: + type: string + + ActResult: + type: object + required: + - success + - message + - actions + properties: + success: + type: boolean + description: Whether the action succeeded + message: + type: string + description: Result message + actions: + type: array + items: + $ref: '#/components/schemas/Action' + description: Actions that were executed + + ExtractResult: + oneOf: + - type: object + description: Default extraction result + properties: + extraction: + type: string + - type: object + description: Structured data matching provided schema + additionalProperties: true + + ObserveResult: + type: array + items: + $ref: '#/components/schemas/Action' + description: List of observed actions + + AgentResult: + type: object + properties: + message: + type: string + description: Final message from the agent + steps: + type: array + items: + type: object + description: Steps taken by the agent + + NavigateResult: + type: object + nullable: true + description: Navigation response (may be null) + properties: + ok: + type: boolean + status: + type: integer + url: + type: string + + SSEResponse: + description: | + Server-Sent Events stream. Each event is prefixed with "data: " + and contains a JSON object with type and data fields. + type: object + required: + - id + - type + - data + properties: + id: + type: string + format: uuid + description: Unique event ID + type: + type: string + enum: [system, log] + description: Event type + data: + oneOf: + - $ref: '#/components/schemas/SystemEvent' + - $ref: '#/components/schemas/LogEvent' + + SystemEvent: + type: object + properties: + status: + type: string + enum: [starting, connected, finished, error] + description: System status + result: + description: Result data (present when status=finished) + error: + type: string + description: Error message (present when status=error) + + LogEvent: + type: object + required: + - status + - message + properties: + status: + type: string + const: running + message: + type: object + required: + - category + - message + - level + properties: + category: + type: string + description: Log category + message: + type: string + description: Log message + level: + type: integer + minimum: 0 + maximum: 2 + description: Log level (0=error, 1=info, 2=debug) + + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + description: Error message + details: + description: Additional error details + + responses: + BadRequest: + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + SessionNotFound: + description: Session ID not found or expired + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + InternalError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/packages/core/package.json b/packages/core/package.json index 0152611f9..53b9f4c4f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,12 +5,23 @@ "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", "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", @@ -24,6 +35,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" @@ -46,11 +59,14 @@ "@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", + "fastify-type-provider-zod": "^6.1.0", "fetch-cookie": "^3.1.0", "openai": "^4.87.1", "pino": "^9.6.0", @@ -86,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/scripts/generate-openapi.ts b/packages/core/scripts/generate-openapi.ts new file mode 100644 index 000000000..ea22003cd --- /dev/null +++ b/packages/core/scripts/generate-openapi.ts @@ -0,0 +1,280 @@ +/** + * Generate OpenAPI schema from Zod schemas + * + * Run: npx tsx scripts/generate-openapi.ts + * + * This script imports the actual Zod schemas from lib/v3/server/schemas.ts + * to ensure the OpenAPI spec stays in sync with the implementation. + */ + +import { extendZodWithOpenApi, OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Import actual schemas from server +import { + actSchemaV3, + extractSchemaV3, + observeSchemaV3, + agentExecuteSchemaV3, + navigateSchemaV3, +} from '../lib/v3/server/schemas'; + +// Extend Zod with OpenAPI +extendZodWithOpenApi(z); + +// Create registry +const registry = new OpenAPIRegistry(); + +// Register the schemas with OpenAPI names +registry.register('ActRequest', actSchemaV3); +registry.register('ExtractRequest', extractSchemaV3); +registry.register('ObserveRequest', observeSchemaV3); +registry.register('AgentExecuteRequest', agentExecuteSchemaV3); +registry.register('NavigateRequest', navigateSchemaV3); + +// Response Schemas +const ActResultSchema = z.object({ + success: z.boolean(), + message: z.string(), + actions: z.array(ActionSchema) +}).openapi('ActResult'); + +const ExtractResultSchema = z.unknown().openapi('ExtractResult', { + description: 'Extracted data matching provided schema or default extraction object' +}); + +const ObserveResultSchema = z.array(ActionSchema).openapi('ObserveResult'); + +const AgentResultSchema = z.object({ + message: z.string().optional(), + steps: z.array(z.unknown()).optional() +}).openapi('AgentResult'); + +const ErrorResponseSchema = z.object({ + error: z.string(), + details: z.unknown().optional() +}).openapi('ErrorResponse'); + +// ============================================================================ +// Register Routes +// ============================================================================ + +// POST /sessions/start +registry.registerPath({ + method: 'post', + path: '/sessions/start', + summary: 'Create a new browser session', + description: 'Initializes a new Stagehand session with a browser instance. Returns a session ID that must be used for all subsequent requests.', + request: { + body: { + content: { + 'application/json': { + schema: SessionConfigSchema + } + } + } + }, + responses: { + 200: { + description: 'Session created successfully', + content: { + 'application/json': { + schema: z.object({ + sessionId: z.string().uuid(), + available: z.boolean() + }) + } + } + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + } + } +}); + +// Helper to create session-based route +function registerSessionRoute( + path: string, + summary: string, + description: string, + requestSchema: z.ZodTypeAny, + responseSchema: z.ZodTypeAny +) { + registry.registerPath({ + method: 'post', + path: `/sessions/{sessionId}/${path}`, + summary, + description, + request: { + params: z.object({ + sessionId: z.string().uuid() + }), + headers: z.object({ + 'x-stream-response': z.enum(['true', 'false']).optional() + }).passthrough(), + body: { + content: { + 'application/json': { + schema: requestSchema + } + } + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: responseSchema + }, + 'text/event-stream': { + schema: z.string() + } + } + }, + 400: { + description: 'Invalid request', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + }, + 404: { + description: 'Session not found', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + } + } + }); +} + +// Register all session routes using imported schemas +registerSessionRoute( + 'act', + 'Execute an action on the page', + 'Performs a browser action based on natural language instruction or a specific action object.', + actSchemaV3, + ActResultSchema +); + +registerSessionRoute( + 'extract', + 'Extract structured data from the page', + 'Extracts data from the current page using natural language instructions and optional JSON schema.', + extractSchemaV3, + ExtractResultSchema +); + +registerSessionRoute( + 'observe', + 'Observe possible actions on the page', + 'Returns a list of candidate actions that can be performed on the page.', + observeSchemaV3, + ObserveResultSchema +); + +registerSessionRoute( + 'agentExecute', + 'Execute a multi-step agent task', + 'Runs an autonomous agent that can perform multiple actions to complete a complex task.', + agentExecuteSchemaV3, + AgentResultSchema +); + +registerSessionRoute( + 'navigate', + 'Navigate to a URL', + 'Navigates the browser to the specified URL and waits for page load.', + navigateSchemaV3, + z.unknown() +); + +// POST /sessions/{sessionId}/end +registry.registerPath({ + method: 'post', + path: '/sessions/{sessionId}/end', + summary: 'End the session and cleanup resources', + description: 'Closes the browser and cleans up all resources associated with the session.', + request: { + params: z.object({ + sessionId: z.string().uuid() + }) + }, + responses: { + 200: { + description: 'Session ended', + content: { + 'application/json': { + schema: z.object({ + success: z.boolean() + }) + } + } + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema + } + } + } + } +}); + +// ============================================================================ +// Generate OpenAPI Document +// ============================================================================ + +const generator = new OpenApiGeneratorV3(registry.definitions); + +const openApiDocument = generator.generateDocument({ + openapi: '3.0.3', + info: { + title: 'Stagehand P2P Server API', + version: '3.0.0', + description: `HTTP API for remote Stagehand browser automation. This API allows clients to connect to a Stagehand server and execute browser automation tasks remotely. + +All endpoints except /sessions/start require an active session ID. Responses are streamed using Server-Sent Events (SSE) when the \`x-stream-response: true\` header is provided.`, + contact: { + name: 'Browserbase', + url: 'https://browserbase.com' + } + }, + servers: [ + { + url: 'http://localhost:3000/v1', + description: 'Local P2P server' + }, + { + url: 'https://api.stagehand.browserbase.com/v1', + description: 'Cloud API' + } + ] +}); + +// Write to file +const outputPath = path.join(__dirname, '..', 'openapi.yaml'); +const yaml = require('yaml'); +fs.writeFileSync(outputPath, yaml.stringify(openApiDocument)); + +console.log(`✓ OpenAPI schema generated at: ${outputPath}`); diff --git a/packages/core/tests/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", diff --git a/packages/core/tests/global-setup.stagehand.ts b/packages/core/tests/global-setup.stagehand.ts new file mode 100644 index 000000000..950db6b4c --- /dev/null +++ b/packages/core/tests/global-setup.stagehand.ts @@ -0,0 +1,101 @@ +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; + +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() { + 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 = requireStagehandEnvVar("OPENAI_API_KEY", { + scope: "server", + consumer: "local Stagehand server", + }); + + const stagehand = new Stagehand({ + env: "LOCAL", + verbose: 0, + localBrowserLaunchOptions: { + headless: process.env.STAGEHAND_LOCAL_HEADLESS !== "false", + }, + model: { + modelName: serverModel, + apiKey: serverApiKey, + }, + }); + + 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) { + ensureTestEnvLoaded(); + 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..ff4b30a78 --- /dev/null +++ b/packages/core/tests/integration/integration.test.ts @@ -0,0 +1,189 @@ +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"; +import { ensureTestEnvLoaded } from "../support/testEnv"; + +ensureTestEnvLoaded(); + +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..d9da363f0 --- /dev/null +++ b/packages/core/tests/integration/support/stagehandClient.ts @@ -0,0 +1,90 @@ +import { inject } from "vitest"; +import * as Stagehand from "../../../dist/index.js"; +import { + ensureTestEnvLoaded, + getStagehandEnvVar, + requireStagehandEnvVar, +} from "../../support/testEnv"; + +ensureTestEnvLoaded(); + +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 = getStagehandEnvVar(name, { scope: "client" }); + 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 clientApiKey = requireStagehandEnvVar("OPENAI_API_KEY", { + scope: "client", + consumer: `${activeTarget} Stagehand client`, + }); + + const stagehandOptions: Stagehand.V3Options = { + env: activeTarget === "local" ? "LOCAL" : "BROWSERBASE", + verbose: 0, + experimental: false, + logInferenceToFile: false, + model: { + modelName: "openai/gpt-5-mini", + apiKey: clientApiKey, + }, + }; + + 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 stagehand = new Stagehand.Stagehand(stagehandOptions); + const apiRootUrl = normalizedBaseUrl.replace(/\/v1\/?$/, ""); + + 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(); 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/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), }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34aa34dab..f009311ef 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)) @@ -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,12 @@ importers: dotenv: specifier: ^16.4.5 version: 16.5.0 + 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 @@ -167,31 +176,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 +231,34 @@ 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 + 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) + zod: + specifier: 3.25.76 || 4.1.8 + version: 4.1.8 packages/docs: dependencies: @@ -425,12 +437,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==} @@ -981,6 +993,30 @@ 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==} + + '@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'} @@ -1747,6 +1783,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': @@ -1811,6 +1848,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 +2415,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 +2612,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 +2987,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 +3509,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 +3525,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 +3547,20 @@ 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-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==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -3544,6 +3617,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 +4049,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 +4291,13 @@ 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-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==} @@ -4307,6 +4395,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==} @@ -4664,6 +4755,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: @@ -4696,6 +4788,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 +4916,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 +5178,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 +5295,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 +5535,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 +5565,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 +5610,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 +5636,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 +5985,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'} @@ -6168,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} @@ -6743,7 +6896,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 @@ -6776,6 +6929,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 @@ -6783,7 +6937,6 @@ snapshots: puppeteer-core: 22.15.0(bufferutil@4.0.9) transitivePeerDependencies: - bare-buffer - - bufferutil - encoding - supports-color - utf-8-validate @@ -7218,6 +7371,44 @@ 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 + + '@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 @@ -7514,9 +7705,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)) @@ -8010,6 +8201,8 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -8555,7 +8748,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 +8819,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 +9013,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 +9305,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 +9435,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + core-util-is@1.0.3: {} cors@2.8.5: @@ -9924,6 +10126,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 +10142,57 @@ 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-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 + '@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 +10263,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 @@ -10646,6 +10897,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.2.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -10862,6 +11115,18 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-ref-resolver@3.0.0: + 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: {} @@ -10942,7 +11207,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 +11220,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 +11232,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 +11264,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 +11936,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 +12048,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 +12370,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 +12472,8 @@ snapshots: process-warning@4.0.1: {} + process-warning@5.0.0: {} + process@0.11.10: {} progress@2.0.3: {} @@ -12542,6 +12835,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 @@ -12575,6 +12870,8 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + rollup@4.40.1: dependencies: '@types/estree': 1.0.7 @@ -12672,6 +12969,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 +12992,8 @@ snapshots: secure-json-parse@2.7.0: {} + secure-json-parse@4.1.0: {} + semver@7.7.1: {} semver@7.7.2: {} @@ -13178,6 +13481,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + toidentifier@1.0.1: {} token-types@4.2.1: @@ -13510,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