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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Tell callers to return an options object, such as { signal: controller.signal }, rather than the signal itself. Following the current prose produces a value that StagehandCallOptionsSchema rejects.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/docs/v4/reference/stagehand.mdx, line 85:

<comment>Tell callers to return an options object, such as `{ signal: controller.signal }`, rather than the signal itself. Following the current prose produces a value that `StagehandCallOptionsSchema` rejects.</comment>

<file context>
@@ -77,6 +77,14 @@ const stagehand = await Stagehand.create({ browser });
+</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>
+
</file context>
Suggested change
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.
Called immediately before each ordinary RPC call. Return `{ signal: controller.signal }` (or `undefined`) 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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a caller misspells an RPC method name, this schema accepts the key and the requested timeout is silently ignored. Validate methods keys against the registered method names so timeout configuration errors fail during Stagehand.create().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-ts/src/clientSchemas.ts, line 284:

<comment>When a caller misspells an RPC method name, this schema accepts the key and the requested timeout is silently ignored. Validate `methods` keys against the registered method names so timeout configuration errors fail during `Stagehand.create()`.</comment>

<file context>
@@ -267,8 +268,40 @@ export const StagehandBrowserSchema = z
+export const StagehandRPCTimeoutsSchema = z
+  .strictObject({
+    defaultMs: RPCTimeoutMsSchema.optional(),
+    methods: z.record(z.string(), RPCTimeoutMsSchema).optional(),
+  })
+  .meta({ id: "StagehandRPCTimeouts" });
</file context>

})
.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 @@ -118,19 +118,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?.();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When local parameter validation fails, getCallOptions still runs even though send never issues an RPC. Move the hook invocation to immediately before waitForResponse/cdp.send so it runs only for an actual ordinary outbound call.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-ts/src/rpcClient.ts, line 185:

<comment>When local parameter validation fails, `getCallOptions` still runs even though `send` never issues an RPC. Move the hook invocation to immediately before `waitForResponse`/`cdp.send` so it runs only for an actual ordinary outbound call.</comment>

<file context>
@@ -163,6 +178,16 @@ export class RPCClient {
+      method.name === StagehandMethods.stagehandInit.name ||
+      method.name === StagehandMethods.stagehandCallbackBatch.name
+        ? undefined
+        : this.getCallOptions?.();
+    const callSignal = combineAbortSignals(
+      options.signal,
</file context>

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