Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/configurable-rpc-timeouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": minor
---

Add configurable response timeouts and per-call abort signal propagation for ordinary TypeScript SDK RPC calls.
8 changes: 8 additions & 0 deletions packages/docs/v4/reference/stagehand.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ const stagehand = await Stagehand.create({ browser });
Client-side log level, output format, and optional log callback.
</ParamField>

<ParamField path="rpcTimeouts" type="StagehandRPCTimeouts" optional>
Response-wait deadlines for ordinary Stagehand RPC calls. `defaultMs` applies to every ordinary call; `methods` overrides it for a specific RPC method. Initialization and `experimentalBatch()` retain their existing lifecycle timeout behavior.
</ParamField>

<ParamField path="getCallOptions" type="() => { signal?: AbortSignal } | undefined" optional>
Called immediately before each ordinary RPC call. Return an `AbortSignal` to stop waiting for that call when the signal aborts. Aborting a call does not close the Stagehand instance or its browser.
</ParamField>

<ResponseField name="result" type="Promise<Stagehand>">
An initialized Stagehand instance.
</ResponseField>
Expand Down
35 changes: 35 additions & 0 deletions packages/sdk-ts/src/clientSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
StagehandInitParamsSchema,
StagehandLogLevelSchema,
} from "@browserbasehq/stagehand-protocol/schemas";
import { StagehandMethods } from "@browserbasehq/stagehand-protocol/schema-registry";
import { Page } from "./page.js";
import { Locator } from "./locator.js";
import { isStagehandBrowser, type StagehandBrowser } from "./browser/index.js";
Expand Down Expand Up @@ -267,8 +268,40 @@ export const StagehandBrowserSchema = z
)
.meta({ id: "StagehandBrowser" });

export type StagehandRPCMethodName =
(typeof StagehandMethods)[keyof typeof StagehandMethods]["name"];
export type StagehandRPCTimeouts = {
defaultMs?: number;
methods?: Partial<Record<StagehandRPCMethodName, number>>;
};

const MAX_RPC_TIMEOUT_MS = 2_147_473_647;
const RPCTimeoutMsSchema = z.int().positive().max(MAX_RPC_TIMEOUT_MS);

export const StagehandRPCTimeoutsSchema = z
.strictObject({
defaultMs: RPCTimeoutMsSchema.optional(),
methods: z.record(z.string(), RPCTimeoutMsSchema).optional(),
})
.meta({ id: "StagehandRPCTimeouts" });

export const StagehandCallOptionsSchema = z
.strictObject({
signal: z.custom<AbortSignal>((value) => value instanceof AbortSignal).optional(),
})
.meta({ id: "StagehandCallOptions" });

export const StagehandGetCallOptionsSchema = z
.custom<() => StagehandCallOptions | undefined>(
(value) => typeof value === "function",
"getCallOptions must be a function",
)
.meta({ id: "StagehandGetCallOptions" });

export const StagehandCreateOptionsSchema = StagehandClientCreateConfigSchema.extend({
browser: StagehandBrowserSchema,
rpcTimeouts: StagehandRPCTimeoutsSchema.optional(),
getCallOptions: StagehandGetCallOptionsSchema.optional(),
}).meta({ id: "StagehandCreateOptions" });

export type ClientLLM = z.infer<typeof ClientLLMSchema>;
Expand Down Expand Up @@ -298,6 +331,8 @@ export type ResolvedStagehandClientCreateConfig = z.output<
>;
export type StagehandCreateOptions = z.input<typeof StagehandCreateOptionsSchema>;
export type ResolvedStagehandCreateOptions = z.output<typeof StagehandCreateOptionsSchema>;
export type StagehandCallOptions = z.input<typeof StagehandCallOptionsSchema>;
export type StagehandGetCallOptions = z.input<typeof StagehandGetCallOptionsSchema>;
export type WebMCPToolsOptions = z.infer<typeof WebMCPToolsOptionsSchema>;
export type WebMCPInvokeOptions = z.infer<typeof WebMCPInvokeOptionsSchema>;
export type WebMCPResultOptions = z.infer<typeof WebMCPResultOptionsSchema>;
7 changes: 7 additions & 0 deletions packages/sdk-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,19 +115,26 @@ export {
StagehandClientLoggingConfigSchema,
StagehandClientLogLevelSchema,
StagehandClientCreateConfigSchema,
StagehandCallOptionsSchema,
StagehandBrowserSchema,
StagehandCreateOptionsSchema,
StagehandGetCallOptionsSchema,
StagehandRPCTimeoutsSchema,
WebMCPInvokeOptionsSchema,
WebMCPResultOptionsSchema,
WebMCPToolsOptionsSchema,
type ClientLLM,
type ResolvedStagehandClientLoggingConfig,
type StagehandClientActOptions,
type StagehandCallOptions,
type StagehandClientExtractOptions,
type StagehandClientLoggingConfig,
type StagehandClientObserveOptions,
type StagehandClientCreateConfig,
type StagehandCreateOptions,
type StagehandGetCallOptions,
type StagehandRPCMethodName,
type StagehandRPCTimeouts,
type ResolvedStagehandCreateOptions,
type WebMCPInvokeOptions,
type WebMCPResultOptions,
Expand Down
59 changes: 50 additions & 9 deletions packages/sdk-ts/src/rpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ import {
import type { StagehandRpcNotification } from "@browserbasehq/stagehand-protocol/types";
import { z } from "zod/v4";
import { CDPClient, type ServiceWorkerInfo } from "./cdpClient.js";
import { abortReason } from "./abort.js";
import {
StagehandCallOptionsSchema,
type StagehandGetCallOptions,
type StagehandRPCTimeouts,
} from "./clientSchemas.js";
import { abortReason, throwIfAborted } from "./abort.js";

type PendingRequest = {
method: RPCMethod;
Expand Down Expand Up @@ -144,9 +149,19 @@ export class RPCClient {
pendingNotifications: StagehandRpcNotification[] = [];
closed = false;
readonly cdp: CDPTransport;

constructor(cdp: CDPTransport) {
readonly rpcTimeouts?: StagehandRPCTimeouts;
readonly getCallOptions?: StagehandGetCallOptions;

constructor(
cdp: CDPTransport,
options: {
rpcTimeouts?: StagehandRPCTimeouts;
getCallOptions?: StagehandGetCallOptions;
} = {},
) {
this.cdp = cdp;
this.rpcTimeouts = options.rpcTimeouts;
this.getCallOptions = options.getCallOptions;
this.serviceWorker = cdp.serviceWorker;
this.browserWebSocketDebuggerUrl = cdp.webSocketDebuggerUrl;
this.cdp.onmessage = (message) => this.receive(message);
Expand All @@ -163,6 +178,16 @@ export class RPCClient {
if (method.name === StagehandMethods.stagehandInit.name && !options.signal) {
throw new Error("stagehand.init requires an initialization lifecycle signal");
}
const callOptions =
method.name === StagehandMethods.stagehandInit.name ||
method.name === StagehandMethods.stagehandCallbackBatch.name
? undefined
: this.getCallOptions?.();
const callSignal = combineAbortSignals(
options.signal,
callOptions === undefined ? undefined : StagehandCallOptionsSchema.parse(callOptions).signal,
);
throwIfAborted(callSignal);

const parentContext = context.active();
const span = TRACER.startSpan(
Expand Down Expand Up @@ -190,13 +215,10 @@ export class RPCClient {
...getTraceContextFields(requestContext),
});
span.setAttribute("jsonrpc.request.id", String(request.id));
const responseTimeoutMs = rpcResponseTimeoutMs(method.name, parsedParams);
const responseTimeoutMs = rpcResponseTimeoutMs(method.name, parsedParams, this.rpcTimeouts);
const timeoutController =
responseTimeoutMs === undefined ? undefined : new AbortController();
const signal =
options.signal && timeoutController
? AbortSignal.any([options.signal, timeoutController.signal])
: (options.signal ?? timeoutController?.signal);
const signal = combineAbortSignals(callSignal, timeoutController?.signal);
const timeoutId =
timeoutController && responseTimeoutMs !== undefined
? setTimeout(() => {
Expand All @@ -213,6 +235,7 @@ export class RPCClient {
const [, result] = await Promise.all([
this.cdp.send(request, signal).catch((error: unknown) => {
this.rejectPending(request.id, asError(error));
throw error;
}),
response,
]);
Expand Down Expand Up @@ -509,7 +532,25 @@ function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

export function rpcResponseTimeoutMs(method: string, params: unknown): number | undefined {
function combineAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
const unique = [...new Set(signals.filter((signal) => signal !== undefined))];
if (unique.length === 0) return undefined;
if (unique.length === 1) return unique[0];
return AbortSignal.any(unique);
}

export function rpcResponseTimeoutMs(
method: string,
params: unknown,
configuredTimeouts?: StagehandRPCTimeouts,
): number | undefined {
const configuredTimeoutMs =
method === StagehandMethods.stagehandInit.name ||
method === StagehandMethods.stagehandCallbackBatch.name
? undefined
: ((configuredTimeouts?.methods as Record<string, number> | undefined)?.[method] ??
configuredTimeouts?.defaultMs);
if (configuredTimeoutMs !== undefined) return configuredTimeoutMs;
let operationTimeoutMs: number | undefined;
switch (method) {
case StagehandMethods.stagehandAct.name:
Expand Down
13 changes: 10 additions & 3 deletions packages/sdk-ts/src/stagehand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
StagehandClientObserveOptionsSchema,
type StagehandClientActOptions,
type StagehandClientExtractOptions,
type StagehandGetCallOptions,
type StagehandRPCTimeouts,
type ResolvedStagehandClientLoggingConfig,
type ResolvedStagehandClientCreateConfig,
type StagehandCreateOptions,
Expand Down Expand Up @@ -75,12 +77,17 @@ export class Stagehand {
private constructor(
private readonly browserHandle: StagehandBrowser,
private readonly createConfig: ResolvedStagehandClientCreateConfig,
private readonly rpcClientOptions: {
rpcTimeouts?: StagehandRPCTimeouts;
getCallOptions?: StagehandGetCallOptions;
},
) {}

static async create(input: StagehandCreateOptions): Promise<Stagehand> {
const { browser, ...createConfig } = StagehandCreateOptionsSchema.parse(input);
const { browser, rpcTimeouts, getCallOptions, ...createConfig } =
StagehandCreateOptionsSchema.parse(input);
const claimedBrowser = claimStagehandBrowser(browser);
const stagehand = new Stagehand(browser, createConfig);
const stagehand = new Stagehand(browser, createConfig, { rpcTimeouts, getCallOptions });
let lifecycleSignal: AbortSignal | undefined;
try {
await withStagehandInitDeadline((signal) => {
Expand Down Expand Up @@ -171,7 +178,7 @@ export class Stagehand {

private async initialize(browser: ClaimedStagehandBrowser, signal: AbortSignal): Promise<void> {
const createConfig = this.createConfig;
const rpcClient = new RPCClient(browser.cdpClient);
const rpcClient = new RPCClient(browser.cdpClient, this.rpcClientOptions);
this.rpcClient = rpcClient;

try {
Expand Down
22 changes: 22 additions & 0 deletions packages/sdk-ts/tests/packageContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ describe("published TypeScript SDK", () => {
LocalBrowserConnectOptionsSchema,
Response,
Stagehand,
StagehandCallOptionsSchema,
StagehandGetCallOptionsSchema,
StagehandRPCTimeoutsSchema,
WebMCPInvocation,
WebMCPTool,
WebMCPToolsOptionsSchema,
Expand All @@ -75,6 +78,12 @@ describe("published TypeScript SDK", () => {
}
LocalBrowserConnectOptionsSchema.parse({ cdpUrl: "ws://127.0.0.1:9222" });
BrowserbaseConnectOptionsSchema.parse({ apiKey: "bb_key", sessionId: "session_123" });
StagehandRPCTimeoutsSchema.parse({
defaultMs: 1000,
methods: { "page.goto": 2000 },
});
StagehandCallOptionsSchema.parse({});
StagehandGetCallOptionsSchema.parse(() => undefined);
if (typeof WebMCPTool !== "function") throw new Error("WebMCPTool export is unavailable");
if (typeof WebMCPInvocation !== "function") {
throw new Error("WebMCPInvocation export is unavailable");
Expand Down Expand Up @@ -116,8 +125,12 @@ describe("published TypeScript SDK", () => {
RgbaColor,
SnapshotResult,
StagehandClientActOptions,
StagehandCallOptions,
StagehandClientExtractOptions,
StagehandClientObserveOptions,
StagehandGetCallOptions,
StagehandRPCMethodName,
StagehandRPCTimeouts,
StagehandResultUsage,
Variables,
} from "@browserbasehq/stagehand";
Expand Down Expand Up @@ -149,6 +162,13 @@ describe("published TypeScript SDK", () => {
const actOptions: StagehandClientActOptions = { cache: caching, model, variables };
const observeOptions: StagehandClientObserveOptions = { model, variables };
const extractOptions: StagehandClientExtractOptions = { model };
const rpcMethod: StagehandRPCMethodName = "page.goto";
const rpcTimeouts: StagehandRPCTimeouts = {
defaultMs: 1_000,
methods: { [rpcMethod]: 2_000 },
};
const callOptions: StagehandCallOptions = {};
const getCallOptions: StagehandGetCallOptions = () => callOptions;

declare const centroid: LocatorCentroidResult;
declare const snapshot: SnapshotResult;
Expand All @@ -172,6 +192,8 @@ describe("published TypeScript SDK", () => {
actOptions,
observeOptions,
extractOptions,
rpcTimeouts,
getCallOptions,
centroid,
snapshot,
usage,
Expand Down
Loading
Loading