From 1e259a3f68ec09a1785c67ffbea771aa43bf0fc2 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 23 Mar 2026 15:35:21 -0700 Subject: [PATCH 1/3] add browserSession route handlers --- .../src/routes/v4/browsersession/close.ts | 40 ++ .../v4/browsersession/isAdvancedStealth.ts | 39 ++ .../routes/v4/browsersession/isBrowserbase.ts | 39 ++ .../v4/browsersession/setViewportSize.ts | 48 ++ .../v4/browsersessionMethods.test.ts | 545 ++++++++++++++++++ 5 files changed, 711 insertions(+) create mode 100644 packages/server-v4/src/routes/v4/browsersession/close.ts create mode 100644 packages/server-v4/src/routes/v4/browsersession/isAdvancedStealth.ts create mode 100644 packages/server-v4/src/routes/v4/browsersession/isBrowserbase.ts create mode 100644 packages/server-v4/src/routes/v4/browsersession/setViewportSize.ts create mode 100644 packages/server-v4/test/integration/v4/browsersessionMethods.test.ts diff --git a/packages/server-v4/src/routes/v4/browsersession/close.ts b/packages/server-v4/src/routes/v4/browsersession/close.ts new file mode 100644 index 000000000..be211e132 --- /dev/null +++ b/packages/server-v4/src/routes/v4/browsersession/close.ts @@ -0,0 +1,40 @@ +import type { RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + BrowserSessionCloseActionSchema, + BrowserSessionCloseRequestSchema, + BrowserSessionCloseResponseSchema, +} from "../../../schemas/v4/browserSession.js"; +import { + browserSessionActionErrorResponses, + createBrowserSessionActionHandler, +} from "./shared.js"; + +const closeRoute: RouteOptions = { + method: "POST", + url: "/browsersession/close", + schema: { + operationId: "BrowserSessionClose", + summary: "browserSession.close", + body: BrowserSessionCloseRequestSchema, + response: { + 200: BrowserSessionCloseResponseSchema, + ...browserSessionActionErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createBrowserSessionActionHandler({ + method: "close", + actionSchema: BrowserSessionCloseActionSchema, + execute: async ({ sessionId, sessionStore }) => { + await sessionStore.endSession(sessionId); + return { + result: { + closed: true, + }, + }; + }, + }), +}; + +export default closeRoute; diff --git a/packages/server-v4/src/routes/v4/browsersession/isAdvancedStealth.ts b/packages/server-v4/src/routes/v4/browsersession/isAdvancedStealth.ts new file mode 100644 index 000000000..993846020 --- /dev/null +++ b/packages/server-v4/src/routes/v4/browsersession/isAdvancedStealth.ts @@ -0,0 +1,39 @@ +import type { RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + BrowserSessionIsAdvancedStealthActionSchema, + BrowserSessionIsAdvancedStealthRequestSchema, + BrowserSessionIsAdvancedStealthResponseSchema, +} from "../../../schemas/v4/browserSession.js"; +import { + browserSessionActionErrorResponses, + createBrowserSessionActionHandler, +} from "./shared.js"; + +const isAdvancedStealthRoute: RouteOptions = { + method: "POST", + url: "/browsersession/isAdvancedStealth", + schema: { + operationId: "BrowserSessionIsAdvancedStealth", + summary: "browserSession.isAdvancedStealth", + body: BrowserSessionIsAdvancedStealthRequestSchema, + response: { + 200: BrowserSessionIsAdvancedStealthResponseSchema, + ...browserSessionActionErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createBrowserSessionActionHandler({ + method: "isAdvancedStealth", + actionSchema: BrowserSessionIsAdvancedStealthActionSchema, + execute: async ({ stagehand }) => { + return { + result: { + isAdvancedStealth: stagehand.isAdvancedStealth, + }, + }; + }, + }), +}; + +export default isAdvancedStealthRoute; diff --git a/packages/server-v4/src/routes/v4/browsersession/isBrowserbase.ts b/packages/server-v4/src/routes/v4/browsersession/isBrowserbase.ts new file mode 100644 index 000000000..69f01ff34 --- /dev/null +++ b/packages/server-v4/src/routes/v4/browsersession/isBrowserbase.ts @@ -0,0 +1,39 @@ +import type { RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + BrowserSessionIsBrowserbaseActionSchema, + BrowserSessionIsBrowserbaseRequestSchema, + BrowserSessionIsBrowserbaseResponseSchema, +} from "../../../schemas/v4/browserSession.js"; +import { + browserSessionActionErrorResponses, + createBrowserSessionActionHandler, +} from "./shared.js"; + +const isBrowserbaseRoute: RouteOptions = { + method: "POST", + url: "/browsersession/isBrowserbase", + schema: { + operationId: "BrowserSessionIsBrowserbase", + summary: "browserSession.isBrowserbase", + body: BrowserSessionIsBrowserbaseRequestSchema, + response: { + 200: BrowserSessionIsBrowserbaseResponseSchema, + ...browserSessionActionErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createBrowserSessionActionHandler({ + method: "isBrowserbase", + actionSchema: BrowserSessionIsBrowserbaseActionSchema, + execute: async ({ stagehand }) => { + return { + result: { + isBrowserbase: stagehand.isBrowserbase, + }, + }; + }, + }), +}; + +export default isBrowserbaseRoute; diff --git a/packages/server-v4/src/routes/v4/browsersession/setViewportSize.ts b/packages/server-v4/src/routes/v4/browsersession/setViewportSize.ts new file mode 100644 index 000000000..eb45a6902 --- /dev/null +++ b/packages/server-v4/src/routes/v4/browsersession/setViewportSize.ts @@ -0,0 +1,48 @@ +import type { RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + BrowserSessionSetViewportSizeActionSchema, + BrowserSessionSetViewportSizeRequestSchema, + BrowserSessionSetViewportSizeResponseSchema, +} from "../../../schemas/v4/browserSession.js"; +import { + browserSessionActionErrorResponses, + createBrowserSessionActionHandler, +} from "./shared.js"; + +const setViewportSizeRoute: RouteOptions = { + method: "POST", + url: "/browsersession/setViewportSize", + schema: { + operationId: "BrowserSessionSetViewportSize", + summary: "browserSession.setViewportSize", + body: BrowserSessionSetViewportSizeRequestSchema, + response: { + 200: BrowserSessionSetViewportSizeResponseSchema, + ...browserSessionActionErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createBrowserSessionActionHandler({ + method: "setViewportSize", + actionSchema: BrowserSessionSetViewportSizeActionSchema, + execute: async ({ stagehand, params }) => { + const page = await stagehand.context.awaitActivePage(); + await page.setViewportSize(params.width, params.height, { + deviceScaleFactor: params.deviceScaleFactor, + }); + return { + pageId: page.targetId(), + result: { + width: params.width, + height: params.height, + ...(params.deviceScaleFactor !== undefined + ? { deviceScaleFactor: params.deviceScaleFactor } + : {}), + }, + }; + }, + }), +}; + +export default setViewportSizeRoute; diff --git a/packages/server-v4/test/integration/v4/browsersessionMethods.test.ts b/packages/server-v4/test/integration/v4/browsersessionMethods.test.ts new file mode 100644 index 000000000..2885f2f84 --- /dev/null +++ b/packages/server-v4/test/integration/v4/browsersessionMethods.test.ts @@ -0,0 +1,545 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { after, before, describe, it } from "node:test"; + +import type { Browser } from "playwright"; +import { chromium } from "playwright"; + +import { + assertFetchOk, + assertFetchStatus, + createSessionWithCdp, + endSession, + fetchWithContext, + getBaseUrl, + HTTP_BAD_REQUEST, + HTTP_NOT_FOUND, + HTTP_OK, + getHeaders, +} from "../utils.js"; + +interface BrowserSessionActionRecord { + id: string; + method: string; + status: string; + sessionId: string; + pageId?: string; + createdAt?: string; + updatedAt?: string; + completedAt?: string; + error?: string | null; + [key: string]: unknown; +} + +interface BrowserSessionActionResponse { + success: boolean; + error: string | null; + message?: string; + statusCode?: number; + stack?: string | null; + action?: BrowserSessionActionRecord; +} + +interface BrowserSessionStatusResponse { + success: boolean; + message?: string; + data?: { + browserSession: { + id: string; + status: string; + }; + }; +} + +const headers = getHeaders("4.0.0"); + +async function withBrowser( + cdpUrl: string, + fn: (browser: Browser) => Promise, +): Promise { + const browser = await chromium.connectOverCDP(cdpUrl); + try { + return await fn(browser); + } finally { + await browser.close(); + } +} + +async function postBrowserSessionRoute( + path: string, + sessionId: string, + params: Record, +) { + return fetchWithContext( + `${getBaseUrl()}/v4/browsersession/${path}`, + { + method: "POST", + headers, + body: JSON.stringify({ + sessionId, + ...params, + }), + }, + ); +} + +function assertSuccessAction( + ctx: Awaited>, + expectedMethod: string, +): BrowserSessionActionRecord { + assertFetchStatus(ctx, HTTP_OK); + assertFetchOk(ctx.body !== null, "Expected a JSON response body", ctx); + assert.equal(ctx.body.success, true); + assert.equal(ctx.body.error, null); + assertFetchOk( + ctx.body.action !== undefined, + "Expected an action payload", + ctx, + ); + + const action = ctx.body.action; + assert.equal(typeof action.id, "string"); + assert.notEqual(action.id.length, 0); + assert.equal(action.method, expectedMethod); + assert.equal(action.status, "completed"); + + return action; +} + +describe("v4 browsersession method routes", { concurrency: false }, () => { + let sessionId: string; + let cdpUrl: string; + + before(async () => { + ({ sessionId, cdpUrl } = await createSessionWithCdp(headers)); + }); + + after(async () => { + await endSession(sessionId, headers); + }); + + it("POST /v4/browsersession methods expose the context/browser helpers", async () => { + let requestHeaders: Record | null = + null; + const server = createServer((req, res) => { + requestHeaders = req.headers; + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(` + + + + V4 browser session methods + + +
browser-session-ok
+ +`); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + assert.ok(address && typeof address === "object"); + const url = `http://127.0.0.1:${address.port}/`; + + try { + const connectURLCtx = await postBrowserSessionRoute( + "connectURL", + sessionId, + {}, + ); + const connectURLAction = assertSuccessAction(connectURLCtx, "connectURL"); + assert.equal( + (connectURLAction.result as { connectURL: string }).connectURL, + cdpUrl, + ); + + const configuredViewportCtx = await postBrowserSessionRoute( + "configuredViewport", + sessionId, + {}, + ); + const configuredViewportAction = assertSuccessAction( + configuredViewportCtx, + "configuredViewport", + ); + assert.equal( + (configuredViewportAction.result as { width: number }).width, + 1288, + ); + assert.equal( + (configuredViewportAction.result as { height: number }).height, + 711, + ); + + const isBrowserbaseCtx = await postBrowserSessionRoute( + "isBrowserbase", + sessionId, + {}, + ); + const isBrowserbaseAction = assertSuccessAction( + isBrowserbaseCtx, + "isBrowserbase", + ); + assert.equal( + (isBrowserbaseAction.result as { isBrowserbase: boolean }) + .isBrowserbase, + false, + ); + + const isAdvancedStealthCtx = await postBrowserSessionRoute( + "isAdvancedStealth", + sessionId, + {}, + ); + const isAdvancedStealthAction = assertSuccessAction( + isAdvancedStealthCtx, + "isAdvancedStealth", + ); + assert.equal( + ( + isAdvancedStealthAction.result as { + isAdvancedStealth: boolean; + } + ).isAdvancedStealth, + false, + ); + + const browserbaseSessionIDCtx = await postBrowserSessionRoute( + "browserbaseSessionID", + sessionId, + {}, + ); + const browserbaseSessionIDAction = assertSuccessAction( + browserbaseSessionIDCtx, + "browserbaseSessionID", + ); + assert.equal( + ( + browserbaseSessionIDAction.result as { + browserbaseSessionID: string | null; + } + ).browserbaseSessionID, + null, + ); + + const browserbaseSessionURLCtx = await postBrowserSessionRoute( + "browserbaseSessionURL", + sessionId, + {}, + ); + const browserbaseSessionURLAction = assertSuccessAction( + browserbaseSessionURLCtx, + "browserbaseSessionURL", + ); + assert.equal( + ( + browserbaseSessionURLAction.result as { + browserbaseSessionURL: string | null; + } + ).browserbaseSessionURL, + null, + ); + + const browserbaseDebugURLCtx = await postBrowserSessionRoute( + "browserbaseDebugURL", + sessionId, + {}, + ); + const browserbaseDebugURLAction = assertSuccessAction( + browserbaseDebugURLCtx, + "browserbaseDebugURL", + ); + assert.equal( + ( + browserbaseDebugURLAction.result as { + browserbaseDebugURL: string | null; + } + ).browserbaseDebugURL, + null, + ); + + const addInitScriptCtx = await postBrowserSessionRoute( + "addInitScript", + sessionId, + { + script: "window.__ctxInitValue = 'present';", + }, + ); + const addInitScriptAction = assertSuccessAction( + addInitScriptCtx, + "addInitScript", + ); + assert.equal( + (addInitScriptAction.result as { added: boolean }).added, + true, + ); + + const setHeadersCtx = await postBrowserSessionRoute( + "setExtraHTTPHeaders", + sessionId, + { + headers: { + "x-stagehand-test": "present", + }, + }, + ); + const setHeadersAction = assertSuccessAction( + setHeadersCtx, + "setExtraHTTPHeaders", + ); + assert.equal( + ( + setHeadersAction.result as { + headers: Record; + } + ).headers["x-stagehand-test"], + "present", + ); + + const newPageCtx = await postBrowserSessionRoute("newPage", sessionId, { + url, + }); + const newPageAction = assertSuccessAction(newPageCtx, "newPage"); + const createdPage = ( + newPageAction.result as { + page: { pageId: string; mainFrameId: string; url: string }; + } + ).page; + assert.equal(createdPage.url, url); + + const pagesCtx = await postBrowserSessionRoute("pages", sessionId, {}); + const pagesAction = assertSuccessAction(pagesCtx, "pages"); + const pages = ( + pagesAction.result as { + pages: Array<{ pageId: string }>; + } + ).pages; + assert.ok(pages.some((page) => page.pageId === createdPage.pageId)); + + const activePageCtx = await postBrowserSessionRoute( + "activePage", + sessionId, + {}, + ); + const activePageAction = assertSuccessAction(activePageCtx, "activePage"); + assert.equal( + ( + activePageAction.result as { + page: { pageId: string } | null; + } + ).page?.pageId, + createdPage.pageId, + ); + + const awaitActivePageCtx = await postBrowserSessionRoute( + "awaitActivePage", + sessionId, + { + timeoutMs: 2_000, + }, + ); + const awaitActivePageAction = assertSuccessAction( + awaitActivePageCtx, + "awaitActivePage", + ); + assert.equal( + ( + awaitActivePageAction.result as { + page: { pageId: string }; + } + ).page.pageId, + createdPage.pageId, + ); + + const resolveCtx = await postBrowserSessionRoute( + "resolvePageByMainFrameId", + sessionId, + { + mainFrameId: createdPage.mainFrameId, + }, + ); + const resolveAction = assertSuccessAction( + resolveCtx, + "resolvePageByMainFrameId", + ); + assert.equal( + ( + resolveAction.result as { + page: { pageId: string } | null; + } + ).page?.pageId, + createdPage.pageId, + ); + + const frameTreeCtx = await postBrowserSessionRoute( + "getFullFrameTreeByMainFrameId", + sessionId, + { + mainFrameId: createdPage.mainFrameId, + }, + ); + const frameTreeAction = assertSuccessAction( + frameTreeCtx, + "getFullFrameTreeByMainFrameId", + ); + assert.equal( + ( + frameTreeAction.result as { + frameTree: { frame: { id: string } }; + } + ).frameTree.frame.id, + createdPage.mainFrameId, + ); + + const setViewportSizeCtx = await postBrowserSessionRoute( + "setViewportSize", + sessionId, + { + width: 900, + height: 600, + deviceScaleFactor: 1, + }, + ); + const setViewportSizeAction = assertSuccessAction( + setViewportSizeCtx, + "setViewportSize", + ); + assert.equal( + (setViewportSizeAction.result as { width: number }).width, + 900, + ); + assert.equal( + (setViewportSizeAction.result as { height: number }).height, + 600, + ); + + const addCookiesCtx = await postBrowserSessionRoute( + "addCookies", + sessionId, + { + cookies: [ + { + name: "stagehand-test", + value: "cookie-present", + url, + }, + ], + }, + ); + const addCookiesAction = assertSuccessAction(addCookiesCtx, "addCookies"); + assert.equal((addCookiesAction.result as { added: number }).added, 1); + + const cookiesCtx = await postBrowserSessionRoute("cookies", sessionId, { + urls: url, + }); + const cookiesAction = assertSuccessAction(cookiesCtx, "cookies"); + const cookies = ( + cookiesAction.result as { + cookies: Array<{ name: string; value: string }>; + } + ).cookies; + assert.ok( + cookies.some( + (cookie) => + cookie.name === "stagehand-test" && + cookie.value === "cookie-present", + ), + ); + + const clearCookiesCtx = await postBrowserSessionRoute( + "clearCookies", + sessionId, + { + name: "stagehand-test", + }, + ); + const clearCookiesAction = assertSuccessAction( + clearCookiesCtx, + "clearCookies", + ); + assert.equal( + (clearCookiesAction.result as { cleared: boolean }).cleared, + true, + ); + + const cookiesAfterClearCtx = await postBrowserSessionRoute( + "cookies", + sessionId, + { + urls: url, + }, + ); + const cookiesAfterClearAction = assertSuccessAction( + cookiesAfterClearCtx, + "cookies", + ); + assert.equal( + ( + cookiesAfterClearAction.result as { + cookies: unknown[]; + } + ).cookies.length, + 0, + ); + + await withBrowser(cdpUrl, async (browser) => { + const contexts = browser.contexts(); + assert.ok(contexts.length > 0, "Expected at least one browser context"); + + const deadline = Date.now() + 5_000; + let page = contexts[0]! + .pages() + .find((candidate) => candidate.url() === url); + while (!page && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + page = contexts[0]! + .pages() + .find((candidate) => candidate.url() === url); + } + + assert.ok(page, "Expected to find the new page in the CDP browser"); + await page!.waitForLoadState("load"); + + const viewport = await page!.evaluate(() => ({ + height: window.innerHeight, + init: + (window as typeof window & { __ctxInitValue?: string }) + .__ctxInitValue ?? null, + width: window.innerWidth, + })); + + assert.equal(viewport.init, "present"); + assert.equal(viewport.width, 900); + assert.equal(viewport.height, 600); + }); + + assert.equal(requestHeaders?.["x-stagehand-test"], "present"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + it("POST /v4/browsersession/setExtraHTTPHeaders returns the action error envelope for validation errors", async () => { + const ctx = await fetchWithContext( + `${getBaseUrl()}/v4/browsersession/setExtraHTTPHeaders`, + { + method: "POST", + headers, + body: JSON.stringify({ + sessionId, + }), + }, + ); + + assertFetchStatus(ctx, HTTP_BAD_REQUEST); + assertFetchOk(ctx.body !== null, "Expected a JSON response body", ctx); + assert.equal(ctx.body.success, false); + assert.equal(typeof ctx.body.error, "string"); + assert.equal(ctx.body.statusCode, HTTP_BAD_REQUEST); + }); +}); From 9952cf614c6836dd41c1d584e2787455b71fdc5d Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 23 Mar 2026 15:38:45 -0700 Subject: [PATCH 2/3] add sessionStoreManager and SessionStore interfaces --- .../server-v4/src/lib/InMemorySessionStore.ts | 392 ++++++++++++++++++ packages/server-v4/src/lib/SessionStore.ts | 184 ++++++++ packages/server-v4/src/lib/auth.ts | 14 + packages/server-v4/src/lib/env.ts | 18 + packages/server-v4/src/lib/errorHandler.ts | 70 ++++ packages/server-v4/src/lib/header.ts | 77 ++++ packages/server-v4/src/lib/response.ts | 48 +++ .../server-v4/src/lib/sessionStoreManager.ts | 27 ++ packages/server-v4/src/lib/utils.ts | 220 ++++++++++ 9 files changed, 1050 insertions(+) create mode 100644 packages/server-v4/src/lib/InMemorySessionStore.ts create mode 100644 packages/server-v4/src/lib/SessionStore.ts create mode 100644 packages/server-v4/src/lib/auth.ts create mode 100644 packages/server-v4/src/lib/env.ts create mode 100644 packages/server-v4/src/lib/errorHandler.ts create mode 100644 packages/server-v4/src/lib/header.ts create mode 100644 packages/server-v4/src/lib/response.ts create mode 100644 packages/server-v4/src/lib/sessionStoreManager.ts create mode 100644 packages/server-v4/src/lib/utils.ts diff --git a/packages/server-v4/src/lib/InMemorySessionStore.ts b/packages/server-v4/src/lib/InMemorySessionStore.ts new file mode 100644 index 000000000..51c571a42 --- /dev/null +++ b/packages/server-v4/src/lib/InMemorySessionStore.ts @@ -0,0 +1,392 @@ +import { randomUUID } from "crypto"; +import type { V3Options, LogLine } from "@browserbasehq/stagehand"; +import { V3 } from "@browserbasehq/stagehand"; +import type { + SessionStore, + CreateSessionParams, + RequestContext, + SessionCacheConfig, + SessionStartResult, +} from "./SessionStore.js"; + +const DEFAULT_MAX_CAPACITY = 100; +const DEFAULT_TTL_MS = 0; // 0 = infinite (no TTL-based eviction) + +/** + * Internal node for LRU linked list + */ +interface LruNode { + sessionId: string; + params: CreateSessionParams; + stagehand: V3 | null; + stagehandInitPromise: Promise | 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); + // Allow process to exit gracefully even if this timer is still active + this.cleanupInterval.unref(); + } + + /** + * 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, + cdpUrl: params.connectUrl ?? "", + 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; + } + + if (node.stagehandInitPromise) { + return await node.stagehandInitPromise; + } + + // Create V3 instance (lazy initialization) + const initPromise = (async () => { + const options = this.buildV3Options( + sessionId, + node.params, + ctx, + node.loggerRef, + ); + const stagehand = new V3(options); + try { + await stagehand.init(); + node.stagehand = stagehand; + return stagehand; + } catch (error) { + try { + await stagehand.close(); + } catch { + // best-effort cleanup for failed init attempts + } + throw error; + } finally { + node.stagehandInitPromise = null; + } + })(); + + node.stagehandInitPromise = initPromise; + return await initPromise; + } + + /** + * Build V3Options from stored params and request context + */ + private buildV3Options( + sessionId: string, + params: CreateSessionParams, + ctx: RequestContext, + loggerRef: { current?: (message: LogLine) => void }, + ): V3Options { + const isBrowserbase = params.browserType === "browserbase"; + + const options: V3Options = { + sessionId, + env: isBrowserbase ? "BROWSERBASE" : "LOCAL", + model: { + modelName: params.modelName, + 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 (isBrowserbase) { + options.apiKey = params.browserbaseApiKey; + options.projectId = params.browserbaseProjectId; + + if (params.browserbaseSessionID) { + options.browserbaseSessionID = params.browserbaseSessionID; + } + + if (params.browserbaseSessionCreateParams) { + options.browserbaseSessionCreateParams = + params.browserbaseSessionCreateParams; + } + } else if (params.localBrowserLaunchOptions) { + options.localBrowserLaunchOptions = params.localBrowserLaunchOptions; + } + + 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 + stagehandInitPromise: null, + 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; + + // 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; + + const stagehand = + node.stagehand ?? + (node.stagehandInitPromise + ? await node.stagehandInitPromise.catch((): null => null) + : null); + + // Close V3 instance if it exists + if (stagehand) { + try { + await stagehand.close(); + } catch (error) { + console.error( + `Error closing stagehand for session ${sessionId}:`, + error, + ); + } + } + } + + async getSessionConfig(sessionId: string): Promise { + const node = this.items.get(sessionId); + + if (!node) { + throw new Error(`Session not found: ${sessionId}`); + } + + // Return the stored params (contains browser metadata needed downstream) + return node.params; + } + + 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/server-v4/src/lib/SessionStore.ts b/packages/server-v4/src/lib/SessionStore.ts new file mode 100644 index 000000000..387cb856f --- /dev/null +++ b/packages/server-v4/src/lib/SessionStore.ts @@ -0,0 +1,184 @@ +import type { + Api, + LocalBrowserLaunchOptions, + LogLine, + V3, +} from "@browserbasehq/stagehand"; + +/** + * Result from SessionStore.startSession(). + */ +export type SessionStartResult = Api.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 { + /** Browser choice for this session */ + browserType: "local" | "browserbase"; + /** 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; + /** Local browser launch overrides when browserType is local */ + localBrowserLaunchOptions?: LocalBrowserLaunchOptions; + + /** WebSocket URL for connecting to the browser (returned to client) */ + connectUrl?: string; + + // Cloud-specific metadata fields + /** 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; + + /** + * Retrieve the stored session configuration for a given session. + * @param sessionId - The session identifier + */ + getSessionConfig(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/server-v4/src/lib/auth.ts b/packages/server-v4/src/lib/auth.ts new file mode 100644 index 000000000..9eb7428b7 --- /dev/null +++ b/packages/server-v4/src/lib/auth.ts @@ -0,0 +1,14 @@ +import type { FastifyRequest } from "fastify"; + +export const authMiddleware = async ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: FastifyRequest, +): Promise => { + // Authentication is currently disabled; we may re-enable when a real auth backend is wired up. + return await isAuthenticated(); +}; + +// TODO: Temporarily disable auth until setup in supabase +const isAuthenticated = async (): Promise => { + return true; +}; diff --git a/packages/server-v4/src/lib/env.ts b/packages/server-v4/src/lib/env.ts new file mode 100644 index 000000000..919732532 --- /dev/null +++ b/packages/server-v4/src/lib/env.ts @@ -0,0 +1,18 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod/v4"; + +// Temporarily defining here until browserbase zod package is updated to 3.25.0+ +const bbEnvSchema = z.enum(["local", "dev", "prod"]); + +export const env = createEnv({ + server: { + NODE_ENV: z.enum(["development", "production", "staging", "test"]), + BB_ENV: bbEnvSchema, + }, + client: {}, + clientPrefix: "PUBLIC_", + runtimeEnv: { + NODE_ENV: process.env.NODE_ENV ?? "production", + BB_ENV: process.env.BB_ENV ?? "local", + }, +}); diff --git a/packages/server-v4/src/lib/errorHandler.ts b/packages/server-v4/src/lib/errorHandler.ts new file mode 100644 index 000000000..1e533fe03 --- /dev/null +++ b/packages/server-v4/src/lib/errorHandler.ts @@ -0,0 +1,70 @@ +import type { + FastifyReply, + FastifyRequest, + RouteGenericInterface, +} from "fastify"; +import { StatusCodes } from "http-status-codes"; + +import { error } from "./response.js"; + +export class AppError extends Error { + statusCode: number; + isInternal: boolean; + + constructor( + message: string, + statusCode = StatusCodes.BAD_REQUEST, + isInternal = false, + ) { + super(message); + this.statusCode = statusCode; + this.isInternal = isInternal; + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } + + /** + * Get the message safe to send to clients. + * For internal errors (5xx), returns generic message. + * For client errors (4xx), returns actual message. + */ + getClientMessage(): string { + if (this.isInternal) { + return this.statusCode >= StatusCodes.INTERNAL_SERVER_ERROR + ? "An internal server error occurred" + : "An error occurred while processing your request"; + } + return this.message; + } +} + +/** + * Wraps a route handler with error handling + * @param handler The route handler to wrap + * @returns A wrapped route handler that catches errors + */ +export function withErrorHandling< + T extends RouteGenericInterface = RouteGenericInterface, + R = unknown, +>(handler: (request: FastifyRequest, reply: FastifyReply) => Promise) { + return async ( + request: FastifyRequest, + reply: FastifyReply, + ): Promise => { + try { + return await handler(request, reply); + } catch (err) { + request.log.error(err); + + if (err instanceof AppError) { + return error(reply, err.getClientMessage(), err.statusCode); + } + + return error( + reply, + "An internal server error occurred", + StatusCodes.INTERNAL_SERVER_ERROR, + ); + } + }; +} diff --git a/packages/server-v4/src/lib/header.ts b/packages/server-v4/src/lib/header.ts new file mode 100644 index 000000000..eefbbac13 --- /dev/null +++ b/packages/server-v4/src/lib/header.ts @@ -0,0 +1,77 @@ +import type { FastifyRequest } from "fastify"; + +import { MissingHeaderError } from "../types/error.js"; + +export const dangerouslyGetHeader = ( + request: FastifyRequest, + header: string, +): string => { + const headerValue = request.headers[header]; + + if (!headerValue) { + throw new MissingHeaderError(header); + } + if (Array.isArray(headerValue)) { + const [value] = headerValue; + if (!value) { + throw new MissingHeaderError(header); + } + return value; + } + return headerValue; +}; + +export const getOptionalHeader = ( + request: FastifyRequest, + header: string, +): string | undefined => { + const headerValue = request.headers[header]; + if (!headerValue) { + return undefined; + } + if (Array.isArray(headerValue)) { + const [value] = headerValue; + if (!value) { + return undefined; + } + return value; + } + return headerValue; +}; + +/** + * Extracts model name from request body, supporting V3 structure. + * - V3: body.options.model.modelName + */ +export function getModelName(request: FastifyRequest): string | undefined { + const body = request.body as Record | undefined; + const options = body?.options as Record | undefined; + const model = options?.model as Record | undefined; + + if (typeof model?.modelName === "string" && model.modelName) { + return model.modelName; + } + + if (typeof body?.modelName === "string" && body.modelName) { + return body.modelName; + } + + return undefined; +} + +/** + * Extracts the model API key with precedence: + * 1. Per-request body apiKey (V3: body.options.model.apiKey) + * 2. Per-request header x-model-api-key + */ +export function getModelApiKey(request: FastifyRequest): string | undefined { + const body = request.body as Record | undefined; + const options = body?.options as Record | undefined; + const model = options?.model as Record | undefined; + + if (typeof model?.apiKey === "string" && model.apiKey) { + return model.apiKey; + } + + return getOptionalHeader(request, "x-model-api-key"); +} diff --git a/packages/server-v4/src/lib/response.ts b/packages/server-v4/src/lib/response.ts new file mode 100644 index 000000000..94bd2aeb3 --- /dev/null +++ b/packages/server-v4/src/lib/response.ts @@ -0,0 +1,48 @@ +import type { FastifyReply } from "fastify"; +import { StatusCodes } from "http-status-codes"; + +interface SuccessResponse { + success: true; + data: T; +} + +interface ErrorResponse { + success: false; + message: string; +} + +type ApiResponse = SuccessResponse | ErrorResponse; + +export function success( + reply: FastifyReply, + data: T, + status = StatusCodes.OK, +): FastifyReply { + return reply.status(status).send({ + success: true, + data, + }); +} + +export function error( + reply: FastifyReply, + message: string, + status = StatusCodes.BAD_REQUEST, +): FastifyReply { + return reply.status(status).send({ + success: false, + message, + }); +} + +export function isSuccessResponse( + response: ApiResponse, +): response is SuccessResponse { + return response.success; +} + +export function isErrorResponse( + response: ApiResponse, +): response is ErrorResponse { + return !response.success; +} diff --git a/packages/server-v4/src/lib/sessionStoreManager.ts b/packages/server-v4/src/lib/sessionStoreManager.ts new file mode 100644 index 000000000..1bd840e21 --- /dev/null +++ b/packages/server-v4/src/lib/sessionStoreManager.ts @@ -0,0 +1,27 @@ +import { InMemorySessionStore } from "./InMemorySessionStore.js"; +import type { SessionCacheConfig, SessionStore } from "./SessionStore.js"; + +let sessionStore: SessionStore | null = null; + +export function initializeSessionStore( + config?: SessionCacheConfig, +): SessionStore { + if (!sessionStore) { + sessionStore = new InMemorySessionStore(config); + } + return sessionStore; +} + +export function getSessionStore(): SessionStore { + if (!sessionStore) { + throw new Error("Session store has not been initialized"); + } + return sessionStore; +} + +export async function destroySessionStore(): Promise { + if (sessionStore) { + await sessionStore.destroy(); + sessionStore = null; + } +} diff --git a/packages/server-v4/src/lib/utils.ts b/packages/server-v4/src/lib/utils.ts new file mode 100644 index 000000000..c736adc64 --- /dev/null +++ b/packages/server-v4/src/lib/utils.ts @@ -0,0 +1,220 @@ +import { StatusCodes } from "http-status-codes"; +import { z } from "zod/v3"; +import type { ZodTypeAny } from "zod/v3"; + +import { LegacyModel, LegacyProvider } from "../types/model.js"; +import { AppError } from "./errorHandler.js"; + +interface JSONSchema { + type?: string | string[]; + properties?: Record; + required?: string[]; + description?: string; + items?: JSONSchema; + enum?: string[]; + minimum?: number; + maximum?: number; + format?: "uri" | "url" | "email" | "uuid"; + anyOf?: JSONSchema[]; + oneOf?: JSONSchema[]; + allOf?: JSONSchema[]; +} + +/** + * Converts a JSON Schema object to a Zod schema. + * @param schema The JSON Schema object to convert + * @returns A Zod schema equivalent to the input JSON Schema + */ +export function jsonSchemaToZod(schema: JSONSchema): ZodTypeAny { + if (Array.isArray(schema.type)) { + const subSchemas = schema.type.map((singleType) => { + const sub = { ...schema, type: singleType }; + return jsonSchemaToZod(sub); + }); + + if (subSchemas.length === 0) { + return z.any(); + } else if (subSchemas.length === 1) { + const [subSchema] = subSchemas; + if (!subSchema) { + return z.any(); + } + return subSchema; + } + return z.union(subSchemas as [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]); + } + + if (schema.anyOf && Array.isArray(schema.anyOf)) { + const subSchemas = schema.anyOf.map((sub) => jsonSchemaToZod(sub)); + if (subSchemas.length === 0) { + return z.any(); + } else if (subSchemas.length === 1) { + const [subSchema] = subSchemas; + if (!subSchema) { + return z.any(); + } + return subSchema; + } + return z.union(subSchemas as [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]); + } + + if (schema.oneOf && Array.isArray(schema.oneOf)) { + const subSchemas = schema.oneOf.map((sub) => jsonSchemaToZod(sub)); + if (subSchemas.length === 0) { + return z.any(); + } else if (subSchemas.length === 1) { + const [subSchema] = subSchemas; + if (!subSchema) { + return z.any(); + } + return subSchema; + } + return z.union(subSchemas as [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]); + } + + switch (schema.type) { + case "object": + if (schema.properties) { + const shape: Record = {}; + for (const key in schema.properties) { + const subSchema = schema.properties[key]; + if (!subSchema) { + throw new AppError( + `Property ${key} is not defined in the schema`, + StatusCodes.BAD_REQUEST, + ); + } + shape[key] = jsonSchemaToZod(subSchema); + } + let zodObject = z.object(shape); + + if (schema.required && Array.isArray(schema.required)) { + const requiredFields = schema.required.reduce>( + (acc, key) => { + acc[key] = true; + return acc; + }, + {}, + ); + zodObject = zodObject.partial().required(requiredFields); + } + + if (schema.description) { + zodObject = zodObject.describe(schema.description); + } + return zodObject; + } + + return z.object({}); + + case "array": + if (schema.items) { + let zodArray = z.array(jsonSchemaToZod(schema.items)); + if (schema.description) { + zodArray = zodArray.describe(schema.description); + } + return zodArray; + } + return z.array(z.any()); + + case "string": { + if (schema.enum) { + return z.string().refine((val) => schema.enum?.includes(val) ?? false); + } + let zodString = z.string(); + + switch (schema.format) { + case "uri": + case "url": + zodString = zodString.url(); + break; + case "email": + zodString = zodString.email(); + break; + case "uuid": + zodString = zodString.uuid(); + break; + default: + } + + if (schema.description) { + zodString = zodString.describe(schema.description); + } + return zodString; + } + + case "integer": // integer is a subset of number + case "number": { + let zodNumber = z.number(); + if (schema.minimum !== undefined) { + zodNumber = zodNumber.min(schema.minimum); + } + if (schema.maximum !== undefined) { + zodNumber = zodNumber.max(schema.maximum); + } + if (schema.description) { + zodNumber = zodNumber.describe(schema.description); + } + return zodNumber; + } + + case "boolean": { + let zodBoolean = z.boolean(); + if (schema.description) { + zodBoolean = zodBoolean.describe(schema.description); + } + return zodBoolean; + } + + case "null": { + let zodNull = z.null(); + if (schema.description) { + zodNull = zodNull.describe(schema.description); + } + return zodNull; + } + + default: + // fallback if no recognized schema.type is present + return z.any(); + } +} + +// This function is legacy and will not be required after complete AISDK migration +export function mapModelToProvider(model: LegacyModel): LegacyProvider { + switch (model) { + case "gpt-4o": + case "gpt-4o-mini": + case "gpt-4o-2024-08-06": + case "gpt-4o-2024-05-13": + case "o1-mini": + case "o1-preview": + case "gpt-4.5-preview": + case "o3-mini": + return "openai"; + case "gemini-1.5-flash": + case "gemini-1.5-pro": + case "gemini-1.5-flash-8b": + case "gemini-2.0-flash-lite": + case "gemini-2.0-flash": + case "gemini-2.5-pro-preview-03-25": + case "gemini-2.5-flash-preview-04-17": + return "google"; + case "cerebras-llama-3.3-70b": + case "cerebras-llama-3.1-8b": + throw new AppError( + "Cerebras models are not supported yet", + StatusCodes.BAD_REQUEST, + ); + case "groq-llama-3.3-70b-specdec": + case "groq-llama-3.3-70b-versatile": + throw new AppError( + "Groq models are not supported yet", + StatusCodes.BAD_REQUEST, + ); + default: { + const errorMessage = `Unknown model: ${String(model)}`; + throw new AppError(errorMessage, StatusCodes.BAD_REQUEST); + } + } +} From 6a0df7d6341ee6ba5f119f03eda509f2d0b556b3 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 30 Mar 2026 09:25:40 -0700 Subject: [PATCH 3/3] add log routes --- packages/server-v4/SDK_RELEASE_WORKFLOW.md | 0 packages/server-v4/src/lib/logging/index.ts | 59 +++ packages/server-v4/src/routes/v4/log/index.ts | 95 +++++ .../server-v4/src/routes/v4/log/routes.ts | 5 + .../src/routes/v4/page/asProtocolFrameTree.ts | 34 ++ .../src/routes/v4/page/getOrdinal.ts | 35 ++ .../server-v4/src/routes/v4/page/mainFrame.ts | 41 ++ .../routes/v4/page/waitForMainLoadState.ts | 33 ++ .../src/routes/v4/shared/routeHelpers.ts | 189 +++++++++ .../server-v4/src/routes/v4/stagehand/act.ts | 52 +++ .../src/routes/v4/stagehand/extract.ts | 61 +++ .../src/routes/v4/stagehand/navigate.ts | 36 ++ .../src/routes/v4/stagehand/observe.ts | 54 +++ .../src/routes/v4/stagehand/routes.ts | 13 + .../src/routes/v4/stagehand/shared.ts | 136 +++++++ packages/server-v4/src/schemas/v4/log.ts | 73 ++++ .../server-v4/src/schemas/v4/stagehand.ts | 374 ++++++++++++++++++ .../server-v4/test/integration/v4/log.test.ts | 212 ++++++++++ .../test/integration/v4/stagehand.test.ts | 145 +++++++ 19 files changed, 1647 insertions(+) create mode 100644 packages/server-v4/SDK_RELEASE_WORKFLOW.md create mode 100644 packages/server-v4/src/lib/logging/index.ts create mode 100644 packages/server-v4/src/routes/v4/log/index.ts create mode 100644 packages/server-v4/src/routes/v4/log/routes.ts create mode 100644 packages/server-v4/src/routes/v4/page/asProtocolFrameTree.ts create mode 100644 packages/server-v4/src/routes/v4/page/getOrdinal.ts create mode 100644 packages/server-v4/src/routes/v4/page/mainFrame.ts create mode 100644 packages/server-v4/src/routes/v4/page/waitForMainLoadState.ts create mode 100644 packages/server-v4/src/routes/v4/shared/routeHelpers.ts create mode 100644 packages/server-v4/src/routes/v4/stagehand/act.ts create mode 100644 packages/server-v4/src/routes/v4/stagehand/extract.ts create mode 100644 packages/server-v4/src/routes/v4/stagehand/navigate.ts create mode 100644 packages/server-v4/src/routes/v4/stagehand/observe.ts create mode 100644 packages/server-v4/src/routes/v4/stagehand/routes.ts create mode 100644 packages/server-v4/src/routes/v4/stagehand/shared.ts create mode 100644 packages/server-v4/src/schemas/v4/log.ts create mode 100644 packages/server-v4/src/schemas/v4/stagehand.ts create mode 100644 packages/server-v4/test/integration/v4/log.test.ts create mode 100644 packages/server-v4/test/integration/v4/stagehand.test.ts diff --git a/packages/server-v4/SDK_RELEASE_WORKFLOW.md b/packages/server-v4/SDK_RELEASE_WORKFLOW.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server-v4/src/lib/logging/index.ts b/packages/server-v4/src/lib/logging/index.ts new file mode 100644 index 000000000..c680c1bf0 --- /dev/null +++ b/packages/server-v4/src/lib/logging/index.ts @@ -0,0 +1,59 @@ +import { FastifyInstance } from "fastify"; + +import { env } from "../../lib/env.js"; + +// List of routes to ignore for request logging in local environments +const ignoredRoutes = ["/healthz", "/readyz"]; + +// Helper function to determine if a request should be logged +const shouldLog = (url: string) => { + return env.BB_ENV !== "local" || !ignoredRoutes.includes(url); +}; + +const logging = (app: FastifyInstance) => { + // Add request logging hooks + app.addHook("onRequest", (req, _reply, done) => { + // Add request ID to response headers + if (shouldLog(req.url)) { + req.log.info( + { + req: { + host: req.hostname, + method: req.method, + remoteAddress: req.ip, + remotePort: req.socket.remotePort, + url: req.url, + }, + reqId: req.id, + }, + "incoming request", + ); + } + done(); + }); + + app.addHook("onResponse", (req, reply, done) => { + if (shouldLog(req.url)) { + req.log.info( + { + reqId: req.id, + req: { + host: req.hostname, + method: req.method, + remoteAddress: req.ip, + remotePort: req.socket.remotePort, + url: req.url, + }, + res: { + statusCode: reply.statusCode, + }, + responseTime: reply.elapsedTime, + }, + "request completed", + ); + } + done(); + }); +}; + +export { logging }; diff --git a/packages/server-v4/src/routes/v4/log/index.ts b/packages/server-v4/src/routes/v4/log/index.ts new file mode 100644 index 000000000..01aac42de --- /dev/null +++ b/packages/server-v4/src/routes/v4/log/index.ts @@ -0,0 +1,95 @@ +import type { RouteHandlerMethod, RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; +import { getEventStore, type EventStoreQuery } from "@browserbasehq/stagehand"; +import { StatusCodes } from "http-status-codes"; + +import { success } from "../../../lib/response.js"; +import { + LogErrorResponseSchema, + LogQuerySchema, + LogResponseSchema, + type LogQuery, +} from "../../../schemas/v4/log.js"; + +function buildEventStoreQuery(query: LogQuery): EventStoreQuery { + return { + sessionId: query.sessionId, + eventId: query.eventId, + eventType: query.eventType, + limit: query.limit, + }; +} + +function openSse(reply: Parameters[1]): void { + reply.raw.writeHead(StatusCodes.OK, { + "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": "*", + }); +} + +const logRouteHandler: RouteHandlerMethod = async (request, reply) => { + const query = request.query as LogQuery; + const eventStore = getEventStore(); + const eventQuery = buildEventStoreQuery(query); + + if (query.follow) { + openSse(reply); + + const events = await eventStore.listEvents(eventQuery); + for (const eventRecord of events) { + reply.raw.write(`data: ${JSON.stringify(eventRecord)}\n\n`); + } + + const unsubscribe = eventStore.subscribe( + { + ...eventQuery, + limit: undefined, + }, + (eventRecord: unknown) => { + reply.raw.write(`data: ${JSON.stringify(eventRecord)}\n\n`); + }, + ); + + const heartbeat = setInterval(() => { + reply.raw.write(": ping\n\n"); + }, 15_000); + heartbeat.unref(); + + const cleanup = () => { + clearInterval(heartbeat); + unsubscribe(); + }; + + reply.raw.on("close", cleanup); + reply.raw.on("error", cleanup); + return reply; + } + + const events = await eventStore.listEvents(eventQuery); + return success(reply, { events }); +}; + +const logRoute: RouteOptions = { + method: "GET", + url: "/log", + schema: { + operationId: "LogList", + summary: "Query or follow flow logger events", + querystring: LogQuerySchema, + response: { + 200: LogResponseSchema, + 400: LogErrorResponseSchema, + 401: LogErrorResponseSchema, + 500: LogErrorResponseSchema, + }, + } satisfies FastifyZodOpenApiSchema, + handler: logRouteHandler, +}; + +export default logRoute; diff --git a/packages/server-v4/src/routes/v4/log/routes.ts b/packages/server-v4/src/routes/v4/log/routes.ts new file mode 100644 index 000000000..9fb261db8 --- /dev/null +++ b/packages/server-v4/src/routes/v4/log/routes.ts @@ -0,0 +1,5 @@ +import type { RouteOptions } from "fastify"; + +import logRoute from "./index.js"; + +export const logRoutes: RouteOptions[] = [logRoute]; diff --git a/packages/server-v4/src/routes/v4/page/asProtocolFrameTree.ts b/packages/server-v4/src/routes/v4/page/asProtocolFrameTree.ts new file mode 100644 index 000000000..20d161c00 --- /dev/null +++ b/packages/server-v4/src/routes/v4/page/asProtocolFrameTree.ts @@ -0,0 +1,34 @@ +import type { RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + PageAsProtocolFrameTreeActionSchema, + PageAsProtocolFrameTreeRequestSchema, + PageAsProtocolFrameTreeResponseSchema, +} from "../../../schemas/v4/page.js"; +import { createPageActionHandler, pageErrorResponses } from "./shared.js"; + +const asProtocolFrameTreeRoute: RouteOptions = { + method: "GET", + url: "/page/asProtocolFrameTree", + schema: { + operationId: "PageAsProtocolFrameTree", + summary: "page.asProtocolFrameTree", + querystring: PageAsProtocolFrameTreeRequestSchema, + response: { + 200: PageAsProtocolFrameTreeResponseSchema, + ...pageErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createPageActionHandler({ + method: "asProtocolFrameTree", + actionSchema: PageAsProtocolFrameTreeActionSchema, + execute: async ({ page, params }) => { + return { + frameTree: page.asProtocolFrameTree(params.rootMainFrameId), + }; + }, + }), +}; + +export default asProtocolFrameTreeRoute; diff --git a/packages/server-v4/src/routes/v4/page/getOrdinal.ts b/packages/server-v4/src/routes/v4/page/getOrdinal.ts new file mode 100644 index 000000000..ebf754ae3 --- /dev/null +++ b/packages/server-v4/src/routes/v4/page/getOrdinal.ts @@ -0,0 +1,35 @@ +import type { RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + PageGetOrdinalActionSchema, + PageGetOrdinalRequestSchema, + PageGetOrdinalResponseSchema, +} from "../../../schemas/v4/page.js"; +import { createPageActionHandler, pageErrorResponses } from "./shared.js"; + +const getOrdinalRoute: RouteOptions = { + method: "GET", + url: "/page/getOrdinal", + schema: { + operationId: "PageGetOrdinal", + summary: "page.getOrdinal", + querystring: PageGetOrdinalRequestSchema, + response: { + 200: PageGetOrdinalResponseSchema, + ...pageErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createPageActionHandler({ + method: "getOrdinal", + actionSchema: PageGetOrdinalActionSchema, + execute: async ({ page, params }) => { + return { + frameId: params.frameId, + ordinal: page.getOrdinal(params.frameId), + }; + }, + }), +}; + +export default getOrdinalRoute; diff --git a/packages/server-v4/src/routes/v4/page/mainFrame.ts b/packages/server-v4/src/routes/v4/page/mainFrame.ts new file mode 100644 index 000000000..5f8333399 --- /dev/null +++ b/packages/server-v4/src/routes/v4/page/mainFrame.ts @@ -0,0 +1,41 @@ +import type { RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + PageMainFrameActionSchema, + PageMainFrameRequestSchema, + PageMainFrameResponseSchema, +} from "../../../schemas/v4/page.js"; +import { createPageActionHandler, pageErrorResponses } from "./shared.js"; + +const mainFrameRoute: RouteOptions = { + method: "GET", + url: "/page/mainFrame", + schema: { + operationId: "PageMainFrame", + summary: "page.mainFrame", + querystring: PageMainFrameRequestSchema, + response: { + 200: PageMainFrameResponseSchema, + ...pageErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createPageActionHandler({ + method: "mainFrame", + actionSchema: PageMainFrameActionSchema, + execute: async ({ page }) => { + const frame = page.mainFrame(); + + return { + frame: { + frameId: frame.frameId, + pageId: frame.pageId, + sessionId: frame.sessionId, + isBrowserRemote: frame.isBrowserRemote(), + }, + }; + }, + }), +}; + +export default mainFrameRoute; diff --git a/packages/server-v4/src/routes/v4/page/waitForMainLoadState.ts b/packages/server-v4/src/routes/v4/page/waitForMainLoadState.ts new file mode 100644 index 000000000..1d8845c07 --- /dev/null +++ b/packages/server-v4/src/routes/v4/page/waitForMainLoadState.ts @@ -0,0 +1,33 @@ +import type { RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + PageWaitForMainLoadStateActionSchema, + PageWaitForMainLoadStateRequestSchema, + PageWaitForMainLoadStateResponseSchema, +} from "../../../schemas/v4/page.js"; +import { createPageActionHandler, pageErrorResponses } from "./shared.js"; + +const waitForMainLoadStateRoute: RouteOptions = { + method: "POST", + url: "/page/waitForMainLoadState", + schema: { + operationId: "PageWaitForMainLoadState", + summary: "page.waitForMainLoadState", + body: PageWaitForMainLoadStateRequestSchema, + response: { + 200: PageWaitForMainLoadStateResponseSchema, + ...pageErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createPageActionHandler({ + method: "waitForMainLoadState", + actionSchema: PageWaitForMainLoadStateActionSchema, + execute: async ({ page, params }) => { + await page.waitForMainLoadState(params.state, params.timeoutMs); + return { state: params.state }; + }, + }), +}; + +export default waitForMainLoadStateRoute; diff --git a/packages/server-v4/src/routes/v4/shared/routeHelpers.ts b/packages/server-v4/src/routes/v4/shared/routeHelpers.ts new file mode 100644 index 000000000..6d6fdab21 --- /dev/null +++ b/packages/server-v4/src/routes/v4/shared/routeHelpers.ts @@ -0,0 +1,189 @@ +import { StatusCodes } from "http-status-codes"; + +import { AppError } from "../../../lib/errorHandler.js"; + +type ErrorClassifierOptions = { + sessionNotFoundMessage?: string; + notFoundMessages?: string[]; + notFoundNames?: string[]; + badRequestMessages?: string[]; + badRequestNames?: string[]; + unprocessableEntityNames?: string[]; + useAppErrorStatus?: boolean; + nameMessageOverrides?: Record; +}; + +export type ClassifiedRouteError = { + message: string; + statusCode: number; +}; + +const COMMON_BAD_REQUEST_ERROR_NAMES = new Set([ + "StagehandInvalidArgumentError", + "StagehandMissingArgumentError", + "StagehandEvalError", + "StagehandLocatorError", +]); + +export class ActionRouteError extends Error { + action: TAction; + cause: unknown; + + constructor(cause: unknown, action: TAction) { + super(cause instanceof Error ? cause.message : String(cause)); + this.name = "ActionRouteError"; + this.action = action; + this.cause = cause; + if (cause instanceof Error && cause.stack) { + this.stack = cause.stack; + } + } +} + +function unwrapRouteError(error: unknown): unknown { + return error instanceof ActionRouteError ? error.cause : error; +} + +export function getRouteErrorStack(error: unknown): string | null { + const cause = unwrapRouteError(error); + return cause instanceof Error ? (cause.stack ?? null) : null; +} + +export function getActionRouteErrorAction( + error: unknown, +): TAction | undefined { + return error instanceof ActionRouteError + ? (error.action as TAction) + : undefined; +} + +export function classifyRouteError( + error: unknown, + options: ErrorClassifierOptions = {}, +): ClassifiedRouteError { + const cause = unwrapRouteError(error); + const message = cause instanceof Error ? cause.message : String(cause); + const name = cause instanceof Error ? cause.name : ""; + + if (message === "Unauthorized") { + return { + message, + statusCode: StatusCodes.UNAUTHORIZED, + }; + } + + if ( + message.startsWith("Session not found:") || + message.startsWith("Session expired:") + ) { + return { + message: options.sessionNotFoundMessage ?? "Session not found", + statusCode: StatusCodes.NOT_FOUND, + }; + } + + if (options.notFoundMessages?.includes(message)) { + return { + message, + statusCode: StatusCodes.NOT_FOUND, + }; + } + + if (options.badRequestMessages?.includes(message)) { + return { + message, + statusCode: StatusCodes.BAD_REQUEST, + }; + } + + if (options.notFoundNames?.includes(name)) { + return { + message: options.nameMessageOverrides?.[name] ?? message, + statusCode: StatusCodes.NOT_FOUND, + }; + } + + if (options.unprocessableEntityNames?.includes(name)) { + return { + message, + statusCode: StatusCodes.UNPROCESSABLE_ENTITY, + }; + } + + if (name === "TimeoutError" || name.endsWith("TimeoutError")) { + return { + message, + statusCode: StatusCodes.REQUEST_TIMEOUT, + }; + } + + if ( + COMMON_BAD_REQUEST_ERROR_NAMES.has(name) || + options.badRequestNames?.includes(name) + ) { + return { + message, + statusCode: StatusCodes.BAD_REQUEST, + }; + } + + if (options.useAppErrorStatus && cause instanceof AppError) { + return { + message, + statusCode: cause.statusCode, + }; + } + + return { + message, + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + }; +} + +export function classifyPageRouteError(error: unknown): ClassifiedRouteError { + return classifyRouteError(error, { + badRequestMessages: ["CDP params must be an object"], + notFoundMessages: ["Page not found"], + notFoundNames: ["StagehandElementNotFoundError"], + unprocessableEntityNames: ["ElementNotVisibleError"], + }); +} + +export function classifyBrowserSessionMethodRouteError( + error: unknown, +): ClassifiedRouteError { + return classifyRouteError(error, { + badRequestNames: [ + "CookieSetError", + "CookieValidationError", + "StagehandSetExtraHTTPHeadersError", + ], + notFoundNames: ["PageNotFoundError"], + sessionNotFoundMessage: "Browser session not found", + }); +} + +export function classifyBrowserSessionLifecycleRouteError( + error: unknown, +): ClassifiedRouteError { + return classifyRouteError(error, { + sessionNotFoundMessage: "Browser session not found", + useAppErrorStatus: true, + }); +} + +export function classifyStagehandRouteError( + error: unknown, +): ClassifiedRouteError { + return classifyRouteError(error, { + notFoundMessages: ["Page not found"], + notFoundNames: ["PageNotFoundError"], + useAppErrorStatus: true, + }); +} + +export function classifyLogRouteError(error: unknown): ClassifiedRouteError { + return classifyRouteError(error, { + useAppErrorStatus: true, + }); +} diff --git a/packages/server-v4/src/routes/v4/stagehand/act.ts b/packages/server-v4/src/routes/v4/stagehand/act.ts new file mode 100644 index 000000000..5f98eb7ea --- /dev/null +++ b/packages/server-v4/src/routes/v4/stagehand/act.ts @@ -0,0 +1,52 @@ +import type { RouteOptions } from "fastify"; +import type { ActOptions } from "@browserbasehq/stagehand"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + StagehandActRequestSchema, + StagehandActResponseSchema, + type StagehandActParams, +} from "../../../schemas/v4/stagehand.js"; +import { + createStagehandRouteHandler, + normalizeStagehandModel, + resolveStagehandPage, + stagehandErrorResponses, +} from "./shared.js"; + +const actRoute: RouteOptions = { + method: "POST", + url: "/stagehand/act", + schema: { + operationId: "StagehandAct", + summary: "stagehand.act", + body: StagehandActRequestSchema, + response: { + 200: StagehandActResponseSchema, + ...stagehandErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createStagehandRouteHandler({ + eventType: "StagehandAct", + execute: async ({ params, stagehand }) => { + const page = await resolveStagehandPage(stagehand, params.frameId); + const options: ActOptions = { + ...params.options, + page, + ...(params.options?.model !== undefined + ? { + model: normalizeStagehandModel( + params.options.model, + ) as ActOptions["model"], + } + : {}), + }; + + return typeof params.input === "string" + ? await stagehand.act(params.input, options) + : await stagehand.act(params.input, options); + }, + }), +}; + +export default actRoute; diff --git a/packages/server-v4/src/routes/v4/stagehand/extract.ts b/packages/server-v4/src/routes/v4/stagehand/extract.ts new file mode 100644 index 000000000..d43fd2d95 --- /dev/null +++ b/packages/server-v4/src/routes/v4/stagehand/extract.ts @@ -0,0 +1,61 @@ +import type { RouteOptions } from "fastify"; +import type { ExtractOptions } from "@browserbasehq/stagehand"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; +import type { ZodTypeAny } from "zod/v3"; + +import { jsonSchemaToZod } from "../../../lib/utils.js"; +import { + StagehandExtractRequestSchema, + StagehandExtractResponseSchema, + type StagehandExtractParams, +} from "../../../schemas/v4/stagehand.js"; +import { + createStagehandRouteHandler, + normalizeStagehandModel, + resolveStagehandPage, + stagehandErrorResponses, +} from "./shared.js"; + +const extractRoute: RouteOptions = { + method: "POST", + url: "/stagehand/extract", + schema: { + operationId: "StagehandExtract", + summary: "stagehand.extract", + body: StagehandExtractRequestSchema, + response: { + 200: StagehandExtractResponseSchema, + ...stagehandErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createStagehandRouteHandler({ + eventType: "StagehandExtract", + execute: async ({ params, stagehand }) => { + const page = await resolveStagehandPage(stagehand, params.frameId); + const options: ExtractOptions = { + ...params.options, + page, + ...(params.options?.model !== undefined + ? { + model: normalizeStagehandModel( + params.options.model, + ) as ExtractOptions["model"], + } + : {}), + }; + + if (params.instruction) { + if (params.schema) { + const schema = jsonSchemaToZod(params.schema) as ZodTypeAny; + return await stagehand.extract(params.instruction, schema, options); + } + + return await stagehand.extract(params.instruction, options); + } + + return await stagehand.extract(options); + }, + }), +}; + +export default extractRoute; diff --git a/packages/server-v4/src/routes/v4/stagehand/navigate.ts b/packages/server-v4/src/routes/v4/stagehand/navigate.ts new file mode 100644 index 000000000..16647eedf --- /dev/null +++ b/packages/server-v4/src/routes/v4/stagehand/navigate.ts @@ -0,0 +1,36 @@ +import type { RouteOptions } from "fastify"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + StagehandNavigateRequestSchema, + StagehandNavigateResponseSchema, + type StagehandNavigateParams, +} from "../../../schemas/v4/stagehand.js"; +import { + createStagehandRouteHandler, + resolveStagehandPage, + stagehandErrorResponses, +} from "./shared.js"; + +const navigateRoute: RouteOptions = { + method: "POST", + url: "/stagehand/navigate", + schema: { + operationId: "StagehandNavigate", + summary: "stagehand.navigate", + body: StagehandNavigateRequestSchema, + response: { + 200: StagehandNavigateResponseSchema, + ...stagehandErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createStagehandRouteHandler({ + eventType: "StagehandNavigate", + execute: async ({ params, stagehand }) => { + const page = await resolveStagehandPage(stagehand, params.frameId); + return await page.goto(params.url, params.options); + }, + }), +}; + +export default navigateRoute; diff --git a/packages/server-v4/src/routes/v4/stagehand/observe.ts b/packages/server-v4/src/routes/v4/stagehand/observe.ts new file mode 100644 index 000000000..f7e928d53 --- /dev/null +++ b/packages/server-v4/src/routes/v4/stagehand/observe.ts @@ -0,0 +1,54 @@ +import type { RouteOptions } from "fastify"; +import type { ObserveOptions } from "@browserbasehq/stagehand"; +import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi"; + +import { + StagehandObserveRequestSchema, + StagehandObserveResponseSchema, + type StagehandObserveParams, +} from "../../../schemas/v4/stagehand.js"; +import { + createStagehandRouteHandler, + normalizeStagehandModel, + resolveStagehandPage, + stagehandErrorResponses, +} from "./shared.js"; + +const observeRoute: RouteOptions = { + method: "POST", + url: "/stagehand/observe", + schema: { + operationId: "StagehandObserve", + summary: "stagehand.observe", + body: StagehandObserveRequestSchema, + response: { + 200: StagehandObserveResponseSchema, + ...stagehandErrorResponses, + }, + } satisfies FastifyZodOpenApiSchema, + handler: createStagehandRouteHandler({ + eventType: "StagehandObserve", + execute: async ({ params, stagehand }) => { + const page = await resolveStagehandPage(stagehand, params.frameId); + const options: ObserveOptions = { + ...params.options, + page, + ...(params.options?.model !== undefined + ? { + model: normalizeStagehandModel( + params.options.model, + ) as ObserveOptions["model"], + } + : {}), + }; + + if (params.instruction) { + return await stagehand.observe(params.instruction, options); + } + + return await stagehand.observe(options); + }, + }), +}; + +export default observeRoute; diff --git a/packages/server-v4/src/routes/v4/stagehand/routes.ts b/packages/server-v4/src/routes/v4/stagehand/routes.ts new file mode 100644 index 000000000..02e1d67c5 --- /dev/null +++ b/packages/server-v4/src/routes/v4/stagehand/routes.ts @@ -0,0 +1,13 @@ +import type { RouteOptions } from "fastify"; + +import actRoute from "./act.js"; +import extractRoute from "./extract.js"; +import navigateRoute from "./navigate.js"; +import observeRoute from "./observe.js"; + +export const stagehandRoutes: RouteOptions[] = [ + actRoute, + extractRoute, + observeRoute, + navigateRoute, +]; diff --git a/packages/server-v4/src/routes/v4/stagehand/shared.ts b/packages/server-v4/src/routes/v4/stagehand/shared.ts new file mode 100644 index 000000000..a8c5b00ae --- /dev/null +++ b/packages/server-v4/src/routes/v4/stagehand/shared.ts @@ -0,0 +1,136 @@ +import type { FastifyRequest, RouteHandlerMethod } from "fastify"; +import { + FlowLogger, + type Stagehand as V3Stagehand, +} from "@browserbasehq/stagehand"; +import { StatusCodes } from "http-status-codes"; + +import { getOptionalHeader } from "../../../lib/header.js"; +import { AppError } from "../../../lib/errorHandler.js"; +import { success } from "../../../lib/response.js"; +import { getSessionStore } from "../../../lib/sessionStoreManager.js"; +import type { RequestContext } from "../../../lib/SessionStore.js"; +import { StagehandErrorResponseSchema } from "../../../schemas/v4/stagehand.js"; + +export const stagehandErrorResponses = { + 400: StagehandErrorResponseSchema, + 401: StagehandErrorResponseSchema, + 404: StagehandErrorResponseSchema, + 408: StagehandErrorResponseSchema, + 422: StagehandErrorResponseSchema, + 500: StagehandErrorResponseSchema, +}; + +type StagehandRequestBody = { + id?: string; + sessionId: string; +} & TParams; + +type StagehandHandlerContext = { + params: TParams; + request: FastifyRequest; + sessionId: string; + stagehand: V3Stagehand; +}; + +function getStagehandModelApiKey( + request: FastifyRequest, + params: TParams, +): string | undefined { + if (typeof params === "object" && params !== null && "options" in params) { + const options = (params as { options?: unknown }).options; + if (typeof options === "object" && options !== null && "model" in options) { + const model = (options as { model?: unknown }).model; + if ( + typeof model === "object" && + model !== null && + "apiKey" in model && + typeof (model as { apiKey?: unknown }).apiKey === "string" + ) { + return (model as { apiKey: string }).apiKey; + } + } + } + + return getOptionalHeader(request, "x-model-api-key"); +} + +export function normalizeStagehandModel( + model: unknown, +): Record | undefined { + if (typeof model === "string") { + return { modelName: model }; + } + + if (typeof model !== "object" || model === null) { + return undefined; + } + + const normalized = { ...(model as Record) }; + if ( + typeof normalized.modelName !== "string" || + normalized.modelName.length === 0 + ) { + normalized.modelName = "gpt-4o"; + } + + return normalized; +} + +export async function resolveStagehandPage( + stagehand: V3Stagehand, + frameId?: string | null, +) { + const page = frameId + ? stagehand.context.resolvePageByMainFrameId(frameId) + : await stagehand.context.awaitActivePage(); + + if (!page) { + throw new AppError("Page not found", StatusCodes.NOT_FOUND); + } + + return page; +} + +export function createStagehandRouteHandler({ + execute, + eventType, +}: { + execute: (ctx: StagehandHandlerContext) => Promise; + eventType: string; +}): RouteHandlerMethod { + return async (request, reply) => { + const input = request.body as StagehandRequestBody; + const { id: _id, sessionId, ...rawParams } = input; + const params = rawParams as TParams; + const requestContext: RequestContext = { + modelApiKey: getStagehandModelApiKey(request, params), + }; + const stagehand = await getSessionStore().getOrCreateStagehand( + sessionId, + requestContext, + ); + + let eventId = ""; + const result = await FlowLogger.runWithLogging( + { + context: stagehand.flowLoggerContext, + eventType, + eventIdSuffix: "1", + eventParentIds: [], + }, + async (loggedParams: TParams) => { + eventId = FlowLogger.currentContext.parentEvents.at(-1)?.eventId ?? ""; + return await execute({ + params: loggedParams, + request, + sessionId, + stagehand, + }); + }, + [params], + ); + + return success(reply, { result, eventId }); + }; +} diff --git a/packages/server-v4/src/schemas/v4/log.ts b/packages/server-v4/src/schemas/v4/log.ts new file mode 100644 index 000000000..603550768 --- /dev/null +++ b/packages/server-v4/src/schemas/v4/log.ts @@ -0,0 +1,73 @@ +import { z } from "zod/v4"; + +import { SessionIdSchema } from "./page.js"; + +export const LogEventSchema = z + .object({ + eventId: z.string().min(1), + eventParentIds: z.array(z.string().min(1)), + createdAt: z.string().min(1), + sessionId: SessionIdSchema, + eventType: z.string().min(1), + data: z + .unknown() + .optional() + .meta({ + override: ({ jsonSchema }: { jsonSchema: Record }) => { + jsonSchema["x-stainless-any"] = true; + }, + }), + }) + .strict() + .meta({ id: "LogEvent" }); + +export const LogQuerySchema = z + .object({ + sessionId: SessionIdSchema.optional(), + eventId: z.string().min(1).optional(), + eventType: z.string().min(1).optional(), + limit: z.coerce.number().int().positive().max(1000).optional(), + follow: z.coerce.boolean().optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (!value.sessionId && !value.eventId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Provide at least one scope filter (sessionId or eventId).", + path: ["sessionId"], + }); + } + }) + .meta({ id: "LogQuery" }); + +export const LogResponseSchema = z + .object({ + success: z.literal(true), + data: z + .object({ + events: z.array(LogEventSchema), + }) + .strict(), + }) + .strict() + .meta({ id: "LogResponse" }); + +export const LogErrorResponseSchema = z + .object({ + success: z.literal(false), + message: z.string(), + }) + .strict() + .meta({ id: "LogErrorResponse" }); + +export const logOpenApiComponents = { + schemas: { + LogEvent: LogEventSchema, + LogQuery: LogQuerySchema, + LogResponse: LogResponseSchema, + LogErrorResponse: LogErrorResponseSchema, + }, +} as const; + +export type LogQuery = z.infer; diff --git a/packages/server-v4/src/schemas/v4/stagehand.ts b/packages/server-v4/src/schemas/v4/stagehand.ts new file mode 100644 index 000000000..7c029866d --- /dev/null +++ b/packages/server-v4/src/schemas/v4/stagehand.ts @@ -0,0 +1,374 @@ +import { z } from "zod/v4"; +import { Api } from "@browserbasehq/stagehand"; + +import { + ActionIdSchema, + FrameIdSchema, + RequestIdSchema, + SessionIdSchema, +} from "./page.js"; + +function wrapStagehandResponse( + resultSchema: T, + id: string, +) { + return z + .object({ + success: z.literal(true), + data: resultSchema, + }) + .strict() + .meta({ id }); +} + +const StagehandBodySchema = z + .object({ + id: RequestIdSchema.optional(), + sessionId: SessionIdSchema, + }) + .strict(); + +function createStagehandRequestSchema>( + id: string, + params: T, +) { + return StagehandBodySchema.extend(params.shape).meta({ id }); +} + +export const StagehandErrorResponseSchema = z + .object({ + success: z.literal(false), + message: z.string(), + }) + .strict() + .meta({ id: "StagehandErrorResponse" }); + +export const StagehandJsonSchemaSchema = z + .record(z.string(), z.unknown()) + .meta({ id: "StagehandJsonSchema" }); + +export const StagehandActOptionsSchema = z + .object({ + model: z.union([Api.ModelConfigSchema, z.string()]).optional().meta({ + description: + "Model configuration object or model name string (e.g. 'openai/gpt-5-nano')", + }), + variables: z + .record(z.string(), z.string()) + .optional() + .meta({ + description: "Variables to substitute in the action instruction", + example: { username: "john_doe" }, + }), + timeout: z.number().optional().meta({ + description: "Timeout in ms for the action", + example: 30000, + }), + }) + .strict() + .optional() + .meta({ id: "StagehandActOptions" }); + +export const StagehandActParamsSchema = z + .object({ + input: z.union([z.string(), Api.ActionSchema]).meta({ + description: "Natural language instruction or Action object", + example: "Click the login button", + }), + options: StagehandActOptionsSchema, + frameId: FrameIdSchema.nullish().meta({ + description: "Target frame ID for the action", + }), + }) + .strict() + .meta({ id: "StagehandActParams" }); + +export const StagehandActRequestSchema = createStagehandRequestSchema( + "StagehandActRequest", + StagehandActParamsSchema, +); + +export const StagehandActResultDataSchema = z + .object({ + success: z.boolean().meta({ + description: "Whether the action completed successfully", + example: true, + }), + message: z.string().meta({ + description: "Human-readable result message", + example: "Successfully clicked the login button", + }), + actionDescription: z.string().meta({ + description: "Description of the action that was performed", + example: "Clicked button with text 'Login'", + }), + actions: z.array(Api.ActionSchema).meta({ + description: "List of actions that were executed", + }), + }) + .strict() + .meta({ id: "StagehandActResultData" }); + +export const StagehandActResultSchema = z + .object({ + result: StagehandActResultDataSchema, + eventId: ActionIdSchema.optional(), + }) + .strict() + .meta({ id: "StagehandActResult" }); + +export const StagehandActResponseSchema = wrapStagehandResponse( + StagehandActResultSchema, + "StagehandActResponse", +); + +export const StagehandExtractOptionsSchema = z + .object({ + model: z.union([Api.ModelConfigSchema, z.string()]).optional().meta({ + description: + "Model configuration object or model name string (e.g. 'openai/gpt-5-nano')", + }), + timeout: z.number().optional().meta({ + description: "Timeout in ms for the extraction", + example: 30000, + }), + selector: z.string().optional().meta({ + description: "CSS selector to scope extraction to a specific element", + example: "#main-content", + }), + }) + .strict() + .optional() + .meta({ id: "StagehandExtractOptions" }); + +export const StagehandExtractParamsSchema = z + .object({ + instruction: z.string().optional().meta({ + description: "Natural language instruction for what to extract", + example: "Extract all product names and prices from the page", + }), + schema: StagehandJsonSchemaSchema.optional().meta({ + description: "JSON Schema defining the structure of data to extract", + }), + options: StagehandExtractOptionsSchema, + frameId: FrameIdSchema.nullish().meta({ + description: "Target frame ID for the extraction", + }), + }) + .strict() + .meta({ id: "StagehandExtractParams" }); + +export const StagehandExtractRequestSchema = createStagehandRequestSchema( + "StagehandExtractRequest", + StagehandExtractParamsSchema, +); + +export const StagehandExtractResultSchema = z + .object({ + result: z.unknown().meta({ + description: "Extracted data matching the requested schema", + override: ({ jsonSchema }: { jsonSchema: Record }) => { + jsonSchema["x-stainless-any"] = true; + }, + }), + eventId: ActionIdSchema.optional(), + }) + .strict() + .meta({ id: "StagehandExtractResult" }); + +export const StagehandExtractResponseSchema = wrapStagehandResponse( + StagehandExtractResultSchema, + "StagehandExtractResponse", +); + +export const StagehandObserveOptionsSchema = z + .object({ + model: z.union([Api.ModelConfigSchema, z.string()]).optional().meta({ + description: + "Model configuration object or model name string (e.g. 'openai/gpt-5-nano')", + }), + timeout: z.number().optional().meta({ + description: "Timeout in ms for the observation", + example: 30000, + }), + selector: z.string().optional().meta({ + description: "CSS selector to scope observation to a specific element", + example: "nav", + }), + }) + .strict() + .optional() + .meta({ id: "StagehandObserveOptions" }); + +export const StagehandObserveParamsSchema = z + .object({ + instruction: z.string().optional().meta({ + description: "Natural language instruction for what actions to find", + example: "Find all clickable navigation links", + }), + options: StagehandObserveOptionsSchema, + frameId: FrameIdSchema.nullish().meta({ + description: "Target frame ID for the observation", + }), + }) + .strict() + .meta({ id: "StagehandObserveParams" }); + +export const StagehandObserveRequestSchema = createStagehandRequestSchema( + "StagehandObserveRequest", + StagehandObserveParamsSchema, +); + +export const StagehandObserveResultSchema = z + .object({ + result: z.array(Api.ActionSchema), + eventId: ActionIdSchema.optional(), + }) + .strict() + .meta({ id: "StagehandObserveResult" }); + +export const StagehandObserveResponseSchema = wrapStagehandResponse( + StagehandObserveResultSchema, + "StagehandObserveResponse", +); + +export const StagehandNavigateOptionsSchema = z + .object({ + referer: z.string().optional().meta({ + description: "Referer header to send with the request", + }), + timeout: z.number().optional().meta({ + description: "Timeout in ms for the navigation", + example: 30000, + }), + waitUntil: z + .enum(["load", "domcontentloaded", "networkidle"]) + .optional() + .meta({ + description: "When to consider navigation complete", + example: "networkidle", + }), + }) + .strict() + .optional() + .meta({ id: "StagehandNavigateOptions" }); + +export const StagehandNavigateParamsSchema = z + .object({ + url: z.string().meta({ + description: "URL to navigate to", + example: "https://example.com", + }), + options: StagehandNavigateOptionsSchema, + frameId: FrameIdSchema.nullish().meta({ + description: "Target frame ID for the navigation", + }), + }) + .strict() + .meta({ id: "StagehandNavigateParams" }); + +export const StagehandNavigateRequestSchema = createStagehandRequestSchema( + "StagehandNavigateRequest", + StagehandNavigateParamsSchema, +); + +export const StagehandNavigateResultSchema = z + .object({ + result: z + .unknown() + .nullable() + .meta({ + description: "Navigation response (Playwright Response object or null)", + override: ({ jsonSchema }: { jsonSchema: Record }) => { + jsonSchema["x-stainless-any"] = true; + }, + }), + eventId: ActionIdSchema.optional(), + }) + .strict() + .meta({ id: "StagehandNavigateResult" }); + +export const StagehandNavigateResponseSchema = wrapStagehandResponse( + StagehandNavigateResultSchema, + "StagehandNavigateResponse", +); + +export const stagehandOpenApiLinks = { + StagehandAct: { + operationId: "StagehandAct", + description: "Perform an action on the browser session", + }, + StagehandExtract: { + operationId: "StagehandExtract", + description: "Extract data from the browser session", + }, + StagehandObserve: { + operationId: "StagehandObserve", + description: "Observe available actions in the browser session", + }, + StagehandNavigate: { + operationId: "StagehandNavigate", + description: "Navigate the active page in the browser session", + }, +} as const; + +export const stagehandOpenApiComponents = { + schemas: { + Action: Api.ActionSchema, + ModelConfig: Api.ModelConfigSchema, + StagehandErrorResponse: StagehandErrorResponseSchema, + StagehandJsonSchema: StagehandJsonSchemaSchema, + StagehandActOptions: StagehandActOptionsSchema, + StagehandActParams: StagehandActParamsSchema, + StagehandActRequest: StagehandActRequestSchema, + StagehandActResultData: StagehandActResultDataSchema, + StagehandActResult: StagehandActResultSchema, + StagehandActResponse: StagehandActResponseSchema, + StagehandExtractOptions: StagehandExtractOptionsSchema, + StagehandExtractParams: StagehandExtractParamsSchema, + StagehandExtractRequest: StagehandExtractRequestSchema, + StagehandExtractResult: StagehandExtractResultSchema, + StagehandExtractResponse: StagehandExtractResponseSchema, + StagehandObserveOptions: StagehandObserveOptionsSchema, + StagehandObserveParams: StagehandObserveParamsSchema, + StagehandObserveRequest: StagehandObserveRequestSchema, + StagehandObserveResult: StagehandObserveResultSchema, + StagehandObserveResponse: StagehandObserveResponseSchema, + StagehandNavigateOptions: StagehandNavigateOptionsSchema, + StagehandNavigateParams: StagehandNavigateParamsSchema, + StagehandNavigateRequest: StagehandNavigateRequestSchema, + StagehandNavigateResult: StagehandNavigateResultSchema, + StagehandNavigateResponse: StagehandNavigateResponseSchema, + }, +}; + +export type StagehandActParams = z.infer; +export type StagehandActRequest = z.infer; +export type StagehandActResponse = z.infer; +export type StagehandExtractParams = z.infer< + typeof StagehandExtractParamsSchema +>; +export type StagehandExtractRequest = z.infer< + typeof StagehandExtractRequestSchema +>; +export type StagehandExtractResponse = z.infer< + typeof StagehandExtractResponseSchema +>; +export type StagehandObserveParams = z.infer< + typeof StagehandObserveParamsSchema +>; +export type StagehandObserveRequest = z.infer< + typeof StagehandObserveRequestSchema +>; +export type StagehandObserveResponse = z.infer< + typeof StagehandObserveResponseSchema +>; +export type StagehandNavigateParams = z.infer< + typeof StagehandNavigateParamsSchema +>; +export type StagehandNavigateRequest = z.infer< + typeof StagehandNavigateRequestSchema +>; +export type StagehandNavigateResponse = z.infer< + typeof StagehandNavigateResponseSchema +>; diff --git a/packages/server-v4/test/integration/v4/log.test.ts b/packages/server-v4/test/integration/v4/log.test.ts new file mode 100644 index 000000000..ee05c7ee9 --- /dev/null +++ b/packages/server-v4/test/integration/v4/log.test.ts @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import { after, before, describe, it } from "node:test"; + +import { + assertFetchOk, + assertFetchStatus, + createSession, + endSession, + fetchWithContext, + getBaseUrl, + getHeaders, + HTTP_BAD_REQUEST, + HTTP_OK, +} from "../utils.js"; + +interface LogEventRecord { + eventId: string; + eventParentIds: string[]; + createdAt: string; + sessionId: string; + eventType: string; + data?: unknown; +} + +interface LogResponseBody { + success: boolean; + message?: string; + data?: { + events: LogEventRecord[]; + }; +} + +const headers = getHeaders("4.0.0"); + +const LOG_TEST_URL = `data:text/html;charset=utf-8,${encodeURIComponent(` + + + + + V4 log route + + +
log-ok
+ + +`)}`; + +async function postPageGoto(sessionId: string) { + return fetchWithContext<{ + success: boolean; + action?: { id: string; method: string; status: string }; + }>(`${getBaseUrl()}/v4/page/goto`, { + method: "POST", + headers, + body: JSON.stringify({ + sessionId, + url: LOG_TEST_URL, + waitUntil: "load", + }), + }); +} + +async function readLogStreamUntil( + sessionId: string, + predicate: (events: LogEventRecord[]) => boolean, +): Promise { + const response = await fetch( + `${getBaseUrl()}/v4/log?sessionId=${encodeURIComponent(sessionId)}&eventType=${encodeURIComponent("PageGoto*")}&follow=true`, + { + method: "GET", + headers, + }, + ); + + assert.equal(response.status, HTTP_OK); + assert.ok( + response.headers.get("content-type")?.startsWith("text/event-stream"), + `Expected text/event-stream response, got ${response.headers.get("content-type")}`, + ); + + const reader = response.body?.getReader(); + assert.ok(reader, "Expected an SSE response body"); + + const decoder = new TextDecoder(); + const events: LogEventRecord[] = []; + let buffer = ""; + const deadline = Date.now() + 10_000; + + for (;;) { + const timeoutMs = deadline - Date.now(); + assert.ok(timeoutMs > 0, "Timed out waiting for /v4/log SSE events"); + + const result = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Timed out waiting for /v4/log SSE chunk")); + }, timeoutMs).unref(); + }), + ]); + + if (result.done) { + break; + } + + buffer += decoder.decode(result.value, { stream: true }); + + for (;;) { + const separatorIndex = buffer.indexOf("\n\n"); + if (separatorIndex === -1) { + break; + } + + const chunk = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + + if (!chunk.startsWith("data: ")) { + continue; + } + + const event = JSON.parse(chunk.slice("data: ".length)) as LogEventRecord; + events.push(event); + + if (predicate(events)) { + await reader.cancel(); + return events; + } + } + } + + await reader.cancel(); + throw new Error("Log stream closed before expected events were received"); +} + +describe("v4 log route", { concurrency: false }, () => { + let sessionId: string; + + before(async () => { + sessionId = await createSession(headers); + }); + + after(async () => { + await endSession(sessionId, headers); + }); + + it("GET /v4/log validates that a scope filter is required", async () => { + const ctx = await fetchWithContext( + `${getBaseUrl()}/v4/log`, + { + method: "GET", + headers, + }, + ); + + assertFetchStatus(ctx, HTTP_BAD_REQUEST); + assertFetchOk(ctx.body !== null, "Expected a JSON response body", ctx); + assert.equal(ctx.body.success, false); + assert.equal(ctx.body.message, "Request validation failed"); + }); + + it("GET /v4/log returns stored session events", async () => { + const gotoCtx = await postPageGoto(sessionId); + assertFetchStatus(gotoCtx, HTTP_OK); + + const ctx = await fetchWithContext( + `${getBaseUrl()}/v4/log?sessionId=${encodeURIComponent(sessionId)}&eventType=${encodeURIComponent("PageGoto*")}`, + { + method: "GET", + headers, + }, + ); + + assertFetchStatus(ctx, HTTP_OK); + assertFetchOk(ctx.body !== null, "Expected a JSON response body", ctx); + assert.equal(ctx.body.success, true); + assert.ok(ctx.body.data); + assert.ok(ctx.body.data.events.length >= 2); + assert.ok( + ctx.body.data.events.some((event) => event.eventType === "PageGotoEvent"), + ); + assert.ok( + ctx.body.data.events.some( + (event) => event.eventType === "PageGotoCompletedEvent", + ), + ); + }); + + it("GET /v4/log streams live session events over SSE", async () => { + const liveSessionId = await createSession(headers); + + const streamPromise = readLogStreamUntil( + liveSessionId, + (events) => + events.some((event) => event.eventType === "PageGotoEvent") && + events.some((event) => event.eventType === "PageGotoCompletedEvent"), + ); + + try { + const gotoCtx = await postPageGoto(liveSessionId); + assertFetchStatus(gotoCtx, HTTP_OK); + + const events = await streamPromise; + assert.ok(events.length >= 2); + assert.ok(events.some((event) => event.eventType === "PageGotoEvent")); + assert.ok( + events.some((event) => event.eventType === "PageGotoCompletedEvent"), + ); + } finally { + await endSession(liveSessionId, headers); + } + }); +}); diff --git a/packages/server-v4/test/integration/v4/stagehand.test.ts b/packages/server-v4/test/integration/v4/stagehand.test.ts new file mode 100644 index 000000000..4acfd8992 --- /dev/null +++ b/packages/server-v4/test/integration/v4/stagehand.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { after, before, describe, it } from "node:test"; + +import type { Page } from "playwright"; +import { chromium } from "playwright"; + +import { + assertFetchOk, + assertFetchStatus, + createSessionWithCdp, + endSession, + fetchWithContext, + getBaseUrl, + getHeaders, + HTTP_BAD_REQUEST, + HTTP_OK, +} from "../utils.js"; + +interface StagehandSuccessBody { + success: boolean; + data?: { + result: TResult; + eventId?: string; + }; + message?: string; +} + +const headers = getHeaders("4.0.0"); + +const NAVIGATE_TEST_URL = `data:text/html;charset=utf-8,${encodeURIComponent(` + + + + + V4 stagehand navigate route + + +
stagehand-navigate-ok
+ + +`)}`; + +async function withSessionPage( + cdpUrl: string, + fn: (page: Page) => Promise, +): Promise { + const browser = await chromium.connectOverCDP(cdpUrl); + + try { + const contexts = browser.contexts(); + assert.ok(contexts.length > 0, "Expected at least one browser context"); + + const pages = contexts[0]!.pages(); + assert.ok(pages.length > 0, "Expected at least one browser page"); + + return await fn(pages[0]!); + } finally { + await browser.close(); + } +} + +async function postStagehandRoute( + path: string, + body: Record, + extraHeaders?: Record, +) { + return fetchWithContext>( + `${getBaseUrl()}/v4/stagehand/${path}`, + { + method: "POST", + headers: { + ...headers, + ...extraHeaders, + }, + body: JSON.stringify(body), + }, + ); +} + +describe("v4 stagehand routes", { concurrency: false }, () => { + let sessionId: string; + let cdpUrl: string; + + before(async () => { + ({ sessionId, cdpUrl } = await createSessionWithCdp(headers)); + }); + + after(async () => { + await endSession(sessionId, headers); + }); + + it("POST /v4/stagehand/navigate navigates the active page", async () => { + const ctx = await postStagehandRoute("navigate", { + sessionId, + url: NAVIGATE_TEST_URL, + options: { + waitUntil: "load", + }, + }); + + assertFetchStatus(ctx, HTTP_OK); + assertFetchOk(ctx.body !== null, "Expected a JSON response body", ctx); + assert.equal(ctx.body.success, true); + assert.equal(typeof ctx.body.data?.eventId, "string"); + assert.ok(ctx.body.data?.eventId); + + await withSessionPage(cdpUrl, async (page) => { + assert.equal(page.url(), NAVIGATE_TEST_URL); + assert.equal(await page.textContent("#message"), "stagehand-navigate-ok"); + }); + }); + + it("POST /v4/stagehand/* routes are registered under the new names", async () => { + const invalidRequests = [ + { + path: "act", + body: { + sessionId, + }, + }, + { + path: "extract", + body: {}, + }, + { + path: "observe", + body: {}, + }, + { + path: "navigate", + body: { + sessionId, + }, + }, + ] as const; + + for (const { path, body } of invalidRequests) { + const ctx = await postStagehandRoute(path, body); + assertFetchStatus(ctx, HTTP_BAD_REQUEST); + assertFetchOk(ctx.body !== null, "Expected a JSON response body", ctx); + assert.equal(ctx.body.success, false); + assert.equal(ctx.body.message, "Request validation failed"); + } + }); +});