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
33 changes: 22 additions & 11 deletions packages/docs/tests/sdk-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1285,26 +1285,37 @@ describe("Mintlify customization boundary", () => {
).toEqual([]);
});

it("includes every MDX content page in docs.json navigation", async () => {
it("includes every indexable MDX content page in docs.json navigation", async () => {
const docsConfig = JSON.parse(
await readFile(resolve(DOCS_ROOT, "docs.json"), "utf8"),
) as unknown;
const navigatedPages = [...collectNavigationPages(docsConfig)]
.filter((page) => page.startsWith("v4/"))
.sort();
const contentPages = (await listFiles(V4_DOCS_ROOT, shouldInspectDocsDirectory))
.filter((filePath) => extname(filePath) === ".mdx")
.map((filePath) =>
relative(DOCS_ROOT, filePath)
.split(sep)
.join("/")
.replace(/\.mdx$/u, ""),
const contentPages = (
await Promise.all(
(
await listFiles(V4_DOCS_ROOT, shouldInspectDocsDirectory)
)
.filter((filePath) => extname(filePath) === ".mdx")
.map(async (filePath) => {
const source = await readFile(filePath, "utf8");
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(source)?.[1] ?? "";
if (/^noindex:\s*true\s*$/mu.test(frontmatter)) return undefined;
return relative(DOCS_ROOT, filePath)
.split(sep)
.join("/")
.replace(/\.mdx$/u, "");
}),
)
)
.filter((page): page is string => page !== undefined)
.sort();

expect(navigatedPages, "Every MDX content page must be reachable from docs.json").toStrictEqual(
contentPages,
);
expect(
navigatedPages,
"Every indexable MDX content page must be reachable from docs.json",
).toStrictEqual(contentPages);
});

it("gives every language tab group the same complete language set", async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@browserbasehq/sdk": "catalog:",
"@browserbasehq/stagehand": "workspace:*",
"@modelcontextprotocol/sdk": "catalog:",
"zod": "catalog:"
Expand Down
2 changes: 1 addition & 1 deletion packages/integrations/core/src/facade/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export const FACADE_AGENT_INSTRUCTIONS = `You control one persistent browser thr
- run: provide either snapshot actions or JavaScript using the Playwright-shaped page API.
- screenshot: inspect the rendered page visually.

Use snapshot actions for simple interactions and run code for multi-step workflows. Pass run exactly one of code or actions; every action uses "op" and "id", never "kind" or "ref". Snapshot IDs are valid only for the latest snapshot of the active page; snapshot again after navigation or stale IDs. Do not launch another browser.`;
Use snapshot actions for simple interactions and run code for multi-step workflows. Pass run exactly one of code or actions; every action uses "op" and "id", never "kind" or "ref". Snapshot IDs are valid only for the latest snapshot of the active page; snapshot again after navigation or stale IDs. Do not launch another browser. Before finishing, close the owned browser session with \`await browser.close()\`. \`page.close()\` only closes the active page.`;

export const NO_HYDRATED_SNAPSHOT_ERROR =
"No hydrated snapshot exists for the active page; call snapshot first.";
Expand Down
11 changes: 10 additions & 1 deletion packages/integrations/core/src/facade/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,18 @@ export {
type RefAction,
type CodeModeRunInput,
} from "./contract.js";
export { StagehandFacadeTools } from "./tools.js";
export {
StagehandFacadeLifecycleError,
StagehandFacadeTools,
type StagehandFacadeLifecycle,
} from "./tools.js";
export {
StagehandFacadeConfigError,
stagehandFacadeConfigFromEnv,
type StagehandFacadeConfig,
} from "./config.js";
export {
BrowserbaseSessionReleaseError,
releaseBrowserbaseSession,
type BrowserbaseSessionRelease,
} from "./session-release.js";
5 changes: 5 additions & 0 deletions packages/integrations/core/src/facade/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1938,6 +1938,11 @@ export async function createPlaywrightCompatRuntime(
record("calls", "context.newPage");
return await createPage(await rawContext.newPage());
},
browser: () => browser,
close: async () => {
record("calls", "context.close");
closeRequested = true;
},
cookies: (urls?: string | string[]) => {
record("calls", "context.cookies");
return rawContext.cookies(urls);
Expand Down
47 changes: 47 additions & 0 deletions packages/integrations/core/src/facade/session-release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Browserbase from "@browserbasehq/sdk";

export type BrowserbaseSessionRelease = {
apiKey: string;
baseUrl?: string;
sessionId: string;
};

const BROWSERBASE_API_URL = "https://api.browserbase.com";
const SESSION_RELEASE_MAX_RETRIES = 2;
const SESSION_RELEASE_TIMEOUT_MS = 10_000;

export class BrowserbaseSessionReleaseError extends Error {
override readonly name = "BrowserbaseSessionReleaseError";

constructor() {
super("Failed to release the Browserbase session.");
}
}

export async function releaseBrowserbaseSession(session: BrowserbaseSessionRelease): Promise<void> {
const browserbase = new Browserbase({
apiKey: session.apiKey,
baseURL: (session.baseUrl ?? BROWSERBASE_API_URL).replace(/\/+$/u, ""),
maxRetries: SESSION_RELEASE_MAX_RETRIES,
timeout: SESSION_RELEASE_TIMEOUT_MS,
});
// The generated SDK currently interpolates path parameters without encoding
// them. Keep the server-issued ID confined to one URL path segment.
const sessionId = encodeURIComponent(session.sessionId);

try {
await browserbase.sessions.update(sessionId, { status: "REQUEST_RELEASE" });
return;
} catch {
// Verify the remote state below before reporting a failed retry.
}

try {
const remoteSession = await browserbase.sessions.retrieve(sessionId);
if (remoteSession.status === "COMPLETED") return;
} catch {
// Fall through to the stable lifecycle error below.
}

throw new BrowserbaseSessionReleaseError();
}
132 changes: 111 additions & 21 deletions packages/integrations/core/src/facade/stdio-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,22 @@ import {
captureScreenshotWithinBase64Budget,
screenshotBase64BudgetFromArgs,
} from "./screenshot-transport.js";
import { releaseBrowserbaseSession } from "./session-release.js";
import { StagehandFacadeTools } from "./tools.js";

type FacadeResources = {
browser: StagehandBrowser;
stagehand: Stagehand;
tools: StagehandFacadeTools;
releaseSession?: () => Promise<void>;
};

const server = new McpServer({ name: "stagehand-facade", version: "4.0.0" });
const screenshotBase64Budget = screenshotBase64BudgetFromArgs(process.argv.slice(2));
let resources: FacadeResources | undefined;
let resourcesPromise: Promise<FacadeResources> | undefined;
let cleanupPromise: Promise<void> = Promise.resolve();
const cleanupTargets = new Set<() => Promise<void>>();
let closing = false;

server.registerTool(
Expand Down Expand Up @@ -109,11 +114,17 @@ server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
});

async function ensureResources(): Promise<FacadeResources> {
resourcesPromise ??= createResources().catch((error) => {
resourcesPromise = undefined;
throw error;
});
return await resourcesPromise;
if (resources && !resources.browser.closed) return resources;

resourcesPromise ??= cleanupPromise.then(retryCleanupTargets).then(createResources);
const pending = resourcesPromise;
try {
const created = await pending;
if (resourcesPromise === pending) resources = created;
return created;
} finally {
if (resourcesPromise === pending) resourcesPromise = undefined;
}
}

async function createResources(): Promise<FacadeResources> {
Expand All @@ -122,12 +133,88 @@ async function createResources(): Promise<FacadeResources> {
config.browser.type === "browserbase"
? await browserbase.launch(config.browser.launchOptions)
: await localBrowser.launch(config.browser.launchOptions);
const sessionId = browser.sessionId;
let releaseSession: (() => Promise<void>) | undefined;
if (config.browser.type === "browserbase" && sessionId) {
const { apiKey, baseUrl } = config.browser.launchOptions;
releaseSession = () => releaseBrowserbaseSession({ apiKey, baseUrl, sessionId });
}
try {
const stagehand = await Stagehand.create({ browser, ...config.stagehand });
return { browser, stagehand, tools: new StagehandFacadeTools(stagehand) };
let tools: StagehandFacadeTools;
tools = new StagehandFacadeTools(stagehand, {
close: () => closeRequestedResources(tools),
});
return { browser, stagehand, tools, ...(releaseSession ? { releaseSession } : {}) };
} catch (error) {
await browser.close().catch(() => undefined);
throw error;
const cleanupErrors: unknown[] = [error];
let browserCloseFailed = false;
await browser.close().catch((cleanupError) => {
browserCloseFailed = true;
cleanupErrors.push(cleanupError);
});
if (browserCloseFailed && releaseSession) {
await releaseSession().catch((releaseError) => {
cleanupTargets.add(releaseSession);
cleanupErrors.push(releaseError);
});
}
if (cleanupErrors.length === 1) throw error;
throw new AggregateError(
cleanupErrors,
"Stagehand initialization failed and browser cleanup also failed.",
{ cause: error },
);
}
}

async function closeRequestedResources(expected: StagehandFacadeTools): Promise<void> {
const current = resources;
if (!current || current.tools !== expected) return;

resources = undefined;
const closeResult = cleanupPromise.then(() => closeResources(current));
cleanupPromise = closeResult.then(
() => undefined,
() => undefined,
);
await closeResult;
}

async function closeResources(current: FacadeResources): Promise<void> {
const cleanupErrors: unknown[] = [];
let browserCloseFailed = false;
await current.stagehand.close().catch((error) => cleanupErrors.push(error));
await current.browser.close().catch((error) => {
browserCloseFailed = true;
cleanupErrors.push(error);
});
if (browserCloseFailed && current.releaseSession) {
cleanupTargets.add(current.releaseSession);
} else if (current.releaseSession) {
cleanupTargets.delete(current.releaseSession);
}
if (cleanupErrors.length === 1) throw cleanupErrors[0];
if (cleanupErrors.length > 1) {
throw new AggregateError(cleanupErrors, "Failed to close the browser session cleanly.");
}
}

async function retryCleanupTargets(): Promise<void> {
for (const releaseSession of cleanupTargets) {
await releaseSession();
cleanupTargets.delete(releaseSession);
}
}

async function closeResourcesForShutdown(current: FacadeResources): Promise<void> {
try {
await closeResources(current);
} finally {
if (current.releaseSession && cleanupTargets.has(current.releaseSession)) {
await current.releaseSession();
cleanupTargets.delete(current.releaseSession);
}
}
}

Expand Down Expand Up @@ -161,22 +248,25 @@ export function sanitizeErrorMessage(message: string): string {
async function shutdown(code: number): Promise<void> {
if (closing) return;
closing = true;
// A launch still in flight must not stall shutdown past the grace window.
const resources = await Promise.race([
resourcesPromise?.catch(() => undefined),
// Wait for a launch and any explicit close already in flight. Failed close
// targets remain tracked so shutdown can make one final best-effort attempt.
const launched = await Promise.race([
(async () => {
const pending = await resourcesPromise?.catch(() => undefined);
await cleanupPromise;
return pending;
})(),
new Promise<undefined>((resolve) => setTimeout(resolve, 5_000)),
]);
const retryTargets = new Set(cleanupTargets);
const activeTargets = new Set<FacadeResources>();
if (resources) activeTargets.add(resources);
if (launched) activeTargets.add(launched);
const clean = await closeCodeModeStdio([
...(resources
? [
{
close: async () => {
await resources.stagehand.close().catch(() => undefined);
await resources.browser.close();
},
},
]
: []),
...[...retryTargets].map((releaseSession) => ({ close: releaseSession })),
...[...activeTargets].map((target) => ({
close: () => closeResourcesForShutdown(target),
})),
server,
]);
if (!clean) process.stderr.write("Failed to close Stagehand facade cleanly.\n");
Expand Down
Loading
Loading