Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion assets/templates/browserbase/cua/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ npm install @browserbasehq/stagehand fastify
|----------|---------|-------------|
| `CUA_SERVER_PORT` | `3000` | Server port |
| `CUA_SERVER_HOST` | `0.0.0.0` | Server host |
| `CUA_SESSION_CREATE_MAX_CONCURRENT` | `2` | Max concurrent session-create inits |
| `CUA_SESSION_CREATE_MAX_PENDING` | `200` | Max queued session-create requests before 503 |

## API Endpoints

Expand Down Expand Up @@ -271,4 +273,3 @@ cua-server/
├── tsconfig.json # TypeScript configuration
└── README.md # This file
```

12 changes: 10 additions & 2 deletions assets/templates/browserbase/cua/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { sessionManager } from "./sessionManager";
import { SessionCreateError, sessionManager } from "./sessionManager";
import { executeAction } from "./actionExecutor";
import { captureBrowserState } from "./stateCapture";
import {
Expand Down Expand Up @@ -46,6 +46,15 @@ export function createServer(): FastifyInstance {
state,
};
} catch (error) {
if (error instanceof SessionCreateError) {
reply.status(error.statusCode);
return {
error: error.message,
code: error.code,
retryable: error.retryable,
statusCode: error.statusCode,
};
}
const errorMessage = error instanceof Error ? error.message : String(error);
reply.status(500);
return { error: errorMessage, code: "SESSION_CREATE_FAILED" };
Expand Down Expand Up @@ -165,4 +174,3 @@ export function createServer(): FastifyInstance {

return server;
}

278 changes: 226 additions & 52 deletions assets/templates/browserbase/cua/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,117 @@ import { Stagehand } from "@browserbasehq/stagehand";
import type { Page } from "@browserbasehq/stagehand";
import { BrowserSession, SessionCreateRequest } from "./types";

const DEFAULT_MAX_CONCURRENT_CREATES = 2;
const DEFAULT_MAX_PENDING_CREATES = 200;

/**
* Generates a unique session ID
*/
function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}

function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

function describeError(error: unknown): string {
if (error instanceof Error) {
return error.message || error.name;
}
if (typeof error === "string") {
return error;
}
return "unknown session creation error";
}

function inferStatusCode(error: unknown): number | undefined {
if (typeof error !== "object" || error === null) {
return undefined;
}
const obj = error as Record<string, unknown>;
const statusCandidate = obj.status ?? obj.statusCode;
if (typeof statusCandidate === "number" && Number.isFinite(statusCandidate)) {
return statusCandidate;
}
const response = obj.response;
if (typeof response === "object" && response !== null) {
const responseStatus = (response as Record<string, unknown>).status;
if (typeof responseStatus === "number" && Number.isFinite(responseStatus)) {
return responseStatus;
}
}
return undefined;
}

export class SessionCreateError extends Error {
readonly code: string;
readonly statusCode: number;
readonly retryable: boolean;

constructor(
message: string,
opts?: { code?: string; statusCode?: number; retryable?: boolean },
) {
super(message);
this.name = "SessionCreateError";
this.code = opts?.code ?? "SESSION_CREATE_FAILED";
this.statusCode = opts?.statusCode ?? 500;
this.retryable = opts?.retryable ?? true;
}
}

function classifySessionCreateError(error: unknown): SessionCreateError {
const statusCode = inferStatusCode(error);
const message = describeError(error);

if (statusCode === 429) {
return new SessionCreateError(message, {
code: "SESSION_RATE_LIMITED",
statusCode: 429,
retryable: true,
});
}
if (statusCode === 401 || statusCode === 403) {
return new SessionCreateError(message, {
code: "SESSION_AUTH_FAILED",
statusCode,
retryable: false,
});
}
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
return new SessionCreateError(message, {
code: "SESSION_PROVIDER_UNAVAILABLE",
statusCode,
retryable: true,
});
}
if (typeof statusCode === "number" && statusCode >= 500) {
return new SessionCreateError(message, {
code: "SESSION_PROVIDER_ERROR",
statusCode,
retryable: true,
});
}
if (typeof statusCode === "number" && statusCode >= 400) {
return new SessionCreateError(message, {
code: "SESSION_CREATE_INVALID_REQUEST",
statusCode,
retryable: false,
});
}

return new SessionCreateError(message, {
code: "SESSION_CREATE_FAILED",
statusCode: 503,
retryable: true,
});
}

/**
* BrowserSessionManager
*
Expand All @@ -17,6 +121,47 @@ function generateSessionId(): string {
*/
export class BrowserSessionManager {
private sessions: Map<string, BrowserSession> = new Map();
private inFlightCreates = 0;
private pendingCreateResolvers: Array<() => void> = [];
private readonly maxConcurrentCreates = parsePositiveInt(
process.env.CUA_SESSION_CREATE_MAX_CONCURRENT,
DEFAULT_MAX_CONCURRENT_CREATES,
);
private readonly maxPendingCreates = parsePositiveInt(
process.env.CUA_SESSION_CREATE_MAX_PENDING,
DEFAULT_MAX_PENDING_CREATES,
);

private async acquireCreateSlot(): Promise<void> {
if (this.inFlightCreates < this.maxConcurrentCreates) {
this.inFlightCreates += 1;
return;
}

if (this.pendingCreateResolvers.length >= this.maxPendingCreates) {
throw new SessionCreateError(
`Session creation queue is full (pending=${this.pendingCreateResolvers.length})`,
{
code: "SESSION_CREATE_QUEUE_FULL",
statusCode: 503,
retryable: true,
},
);
}

await new Promise<void>((resolve) => {
this.pendingCreateResolvers.push(resolve);
});
this.inFlightCreates += 1;
}

private releaseCreateSlot(): void {
this.inFlightCreates = Math.max(0, this.inFlightCreates - 1);
const next = this.pendingCreateResolvers.shift();
if (next) {
next();
}
}

/**
* Create a new browser session
Expand All @@ -25,63 +170,92 @@ export class BrowserSessionManager {
const sessionId = generateSessionId();
const startTime = Date.now();
const envType = options?.env ?? "LOCAL";

console.log(`[Session] Creating ${sessionId} with env: ${envType}, proxies: ${options?.proxies ?? false}`);

// TODO: Update to accept modelApiKey from client request (MODEL_API_KEY) instead of
// hardcoding OPENAI_API_KEY. This will allow using different model providers.
// See: SessionCreateRequest in types.ts, cua_mode.py session_config
const stagehand = new Stagehand({
env: envType,
apiKey: options?.browserbaseApiKey,
projectId: options?.browserbaseProjectId,
modelApiKey: process.env.OPENAI_API_KEY,
verbose: 1,
disablePino: true, // Disable pino logging to avoid pino-pretty transport issues in SEA binaries
browserbaseSessionCreateParams: envType === "BROWSERBASE"
? {
projectId: options?.browserbaseProjectId,
proxies: options?.proxies ?? false,
browserSettings: {
viewport: options?.viewport
? {
width: options.viewport.width,
height: options.viewport.height,
}
: { width: 1024, height: 768 },
},
}
: undefined,
// Only provide localBrowserLaunchOptions for LOCAL mode to avoid Chrome validation in BROWSERBASE mode
localBrowserLaunchOptions: envType === "LOCAL"
? {
viewport: options?.viewport
? {
width: options.viewport.width,
height: options.viewport.height,
}
: { width: 1024, height: 768 },
}
: undefined,
});
await this.acquireCreateSlot();
console.log(
`[Session] Creating ${sessionId} with env: ${envType}, proxies: ${options?.proxies ?? false}, in_flight_creates: ${this.inFlightCreates}, queued_creates: ${this.pendingCreateResolvers.length}`,
);

let stagehand: Stagehand | null = null;
try {
// TODO: Update to accept modelApiKey from client request (MODEL_API_KEY) instead of
// hardcoding OPENAI_API_KEY. This will allow using different model providers.
// See: SessionCreateRequest in types.ts, cua_mode.py session_config
// Stagehand runtime accepts modelApiKey, but some published typings omit it.
// Keep runtime behavior while avoiding type drift failures.
stagehand = new Stagehand({
env: envType,
apiKey: options?.browserbaseApiKey,
projectId: options?.browserbaseProjectId,
modelApiKey: process.env.OPENAI_API_KEY,
verbose: 1,
disablePino: true, // Disable pino logging to avoid pino-pretty transport issues in SEA binaries
browserbaseSessionCreateParams:
envType === "BROWSERBASE"
? {
projectId: options?.browserbaseProjectId,
proxies: options?.proxies ?? false,
browserSettings: {
viewport: options?.viewport
? {
width: options.viewport.width,
height: options.viewport.height,
}
: { width: 1024, height: 768 },
},
}
: undefined,
// Only provide localBrowserLaunchOptions for LOCAL mode to avoid Chrome validation in BROWSERBASE mode
localBrowserLaunchOptions:
envType === "LOCAL"
? {
viewport: options?.viewport
? {
width: options.viewport.width,
height: options.viewport.height,
}
: { width: 1024, height: 768 },
}
: undefined,
} as any);

await stagehand.init();
await stagehand.init();

const page = stagehand.context.pages()[0];
const page = stagehand.context.pages()[0];

const session: BrowserSession = {
id: sessionId,
stagehand,
page,
createdAt: new Date(),
};
const session: BrowserSession = {
id: sessionId,
stagehand,
page,
createdAt: new Date(),
};

this.sessions.set(sessionId, session);

const duration = Date.now() - startTime;
console.log(`[Session] Created ${sessionId} in ${duration}ms (env: ${envType}, active sessions: ${this.sessions.size})`);
this.sessions.set(sessionId, session);

return session;
const duration = Date.now() - startTime;
console.log(
`[Session] Created ${sessionId} in ${duration}ms (env: ${envType}, active sessions: ${this.sessions.size})`,
);

return session;
} catch (error) {
const classified =
error instanceof SessionCreateError
? error
: classifySessionCreateError(error);
console.error(
`[Session] Failed to create ${sessionId}: ${classified.code} (${classified.statusCode}) - ${classified.message}`,
);
if (stagehand) {
try {
await stagehand.close();
} catch {
// no-op: best effort cleanup
}
}
throw classified;
} finally {
this.releaseCreateSlot();
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions assets/templates/browserbase/cua/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,6 @@ export interface BrowserSession {
export interface ErrorResponse {
error: string;
code: string;
retryable?: boolean;
statusCode?: number;
}
Loading
Loading