refactor(examples): use top-level await instead of async IIFE#1727
refactor(examples): use top-level await instead of async IIFE#1727miguelg719 wants to merge 1 commit intomainfrom
Conversation
Now that ESM is supported, examples can use top-level await directly
instead of wrapping code in `(async () => { ... })()` patterns.
This simplifies the examples and makes them more readable.
# why
# what changed
# test plan
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Converted all examples to ESM with top-level await and removed
main/example function wrappers for simpler, linear code. Restored .catch
handlers in cuaReplay.ts and persist_logs_example.ts to ensure errors
are logged, and added a changeset for a patch release.
- **Refactors**
- Moved Stagehand/V3 construction to module scope with try/finally for
cleanup.
<sup>Written for commit 6349d2f.
Summary will update on new commits. <a
href="https://cubic.dev/pr/browserbase/stagehand/pull/1725">Review in
cubic</a></sup>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: Chromie Bot <chromie@browserbase.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 60f1527 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
2 issues found across 33 files
Confidence score: 4/5
- Both issues are in example scripts; impact is limited, so this looks safe to merge with minimal risk overall.
packages/core/examples/v3/cuaReplay.tsdrops the top-level.catch(console.error)during refactor, so errors may go unlogged unless wrapped in a try/catch.packages/core/examples/wordle.tslacks a try/finally to ensurestagehand.close()on failures, risking leaked Browserbase sessions if an await throws.- Pay close attention to
packages/core/examples/v3/cuaReplay.tsandpackages/core/examples/wordle.ts- restore explicit error logging and guaranteed cleanup.
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/core/examples/v3/cuaReplay.ts">
<violation number="1" location="packages/core/examples/v3/cuaReplay.ts:53">
P2: The `.catch(console.error)` error handling from the original `main()` call was dropped during the refactor. To preserve the explicit error logging as intended, wrap the top-level logic in a try/catch. This also ensures `stagehand.close()` is called if `runDemo` throws after `stagehand.init()`.</violation>
</file>
<file name="packages/core/examples/wordle.ts">
<violation number="1" location="packages/core/examples/wordle.ts:18">
P2: Missing `try/finally` to guarantee `stagehand.close()` runs on error. If any `await` throws (e.g., `page.goto` or any `act()` call), the Browserbase session will leak. Other refactored examples in this PR consistently use `try/finally` for cleanup—this one should follow the same pattern.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Script as ESM Script (Top-level)
participant SH as Stagehand / V3 Core
participant Driver as Browser Driver (Playwright/Puppeteer)
participant BB as Browserbase (Remote Browser)
participant LLM as LLM Provider (OpenAI/Anthropic/Gemini)
Note over Script, LLM: NEW: Runtime Flow using ESM Top-level Await
Script->>SH: Instantiate with Config (env, model, verbose)
rect rgb(20, 30, 50)
Note right of Script: CHANGED: Immediate execution (no IIFE wrapper)
Script->>SH: await init()
alt env: "BROWSERBASE"
SH->>BB: Connect via CDP/WebSocket
BB-->>SH: Remote Session
else env: "LOCAL"
SH->>Driver: Launch Local Browser
Driver-->>SH: Local Instance
end
end
Script->>SH: await page.goto(url)
SH->>Driver: Navigate Tab
loop Interaction Loop (act/extract/observe)
Script->>SH: await act(instruction) OR extract(schema)
SH->>Driver: Capture Accessibility Tree / DOM / Screenshot
Driver-->>SH: Page State
SH->>LLM: Send Instruction + Context
LLM-->>SH: Return Action / Structured Data
opt is Action
SH->>Driver: Perform Playwright/Puppeteer Command
end
SH-->>Script: Return Result
end
rect rgb(40, 10, 10)
Note right of Script: NEW: Lifecycle handled in global try/finally
Script->>SH: await close()
SH->>Driver: Cleanup Process / Close Session
end
opt Error Handling
Script->>Script: CHANGED: Errors propagate to top-level catch/process
end
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| level: 1, | ||
| category: "demo", | ||
| message: ` | ||
| const metrics1 = await runDemo(1); |
There was a problem hiding this comment.
P2: The .catch(console.error) error handling from the original main() call was dropped during the refactor. To preserve the explicit error logging as intended, wrap the top-level logic in a try/catch. This also ensures stagehand.close() is called if runDemo throws after stagehand.init().
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/examples/v3/cuaReplay.ts, line 53:
<comment>The `.catch(console.error)` error handling from the original `main()` call was dropped during the refactor. To preserve the explicit error logging as intended, wrap the top-level logic in a try/catch. This also ensures `stagehand.close()` is called if `runDemo` throws after `stagehand.init()`.</comment>
<file context>
@@ -50,30 +50,29 @@ async function runDemo(runNumber: number) {
- level: 1,
- category: "demo",
- message: `
+const metrics1 = await runDemo(1);
+
+v3Logger({
</file context>
| await example(); | ||
| })(); | ||
| await stagehand.act("press enter"); | ||
| await stagehand.close(); |
There was a problem hiding this comment.
P2: Missing try/finally to guarantee stagehand.close() runs on error. If any await throws (e.g., page.goto or any act() call), the Browserbase session will leak. Other refactored examples in this PR consistently use try/finally for cleanup—this one should follow the same pattern.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/examples/wordle.ts, line 18:
<comment>Missing `try/finally` to guarantee `stagehand.close()` runs on error. If any `await` throws (e.g., `page.goto` or any `act()` call), the Browserbase session will leak. Other refactored examples in this PR consistently use `try/finally` for cleanup—this one should follow the same pattern.</comment>
<file context>
@@ -1,24 +1,18 @@
- await example();
-})();
+await stagehand.act("press enter");
+await stagehand.close();
</file context>
Greptile SummaryConverted 33 example files from async IIFE pattern to top-level await, removing wrapper boilerplate and simplifying code structure. Major changes:
Issues found:
The refactoring correctly leverages ESM's top-level await support, but inconsistent cleanup patterns were introduced or left unaddressed. Confidence Score: 2/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Async IIFE Pattern] -->|Refactor| B[Top-level Await Pattern]
A -->|Before| A1["(async () => {<br/> const stagehand = new Stagehand()<br/> await stagehand.init()<br/> // code<br/>})()"]
B -->|After| B1["const stagehand = new Stagehand()<br/>await stagehand.init()<br/>// code"]
B1 --> C{Has Error Handling?}
C -->|Yes - Complete| D["try {<br/> // code<br/>} finally {<br/> await stagehand.close()<br/>}"]
C -->|Partial - Missing finally| E["try {<br/> // code<br/>} catch (error) {<br/> // handle<br/>}<br/>⚠️ No cleanup"]
C -->|No| F["// code runs<br/>⚠️ No cleanup"]
D -->|✅| G[Resources Cleaned Up]
E -->|❌| H[Resource Leak]
F -->|❌| H
style D fill:#90EE90
style E fill:#FFB6C1
style F fill:#FFB6C1
style G fill:#90EE90
style H fill:#FF6B6B
Last reviewed commit: 60f1527 |
| try { | ||
| console.log("🌐 Navigating to 2048..."); | ||
| await page.goto("https://ovolve.github.io/2048-AI/"); | ||
| // Main game loop | ||
| while (true) { | ||
| console.log("🔄 Game loop iteration..."); | ||
| // Add a small delay for UI updates | ||
| await new Promise((resolve) => setTimeout(resolve, 300)); | ||
| // Get current game state | ||
| const gameState = await stagehand.extract( | ||
| `Extract the current game state: | ||
| 1. Score from the score counter | ||
| 2. All tile values in the 4x4 grid (empty spaces as 0) | ||
| 3. Highest tile value present`, | ||
| z.object({ | ||
| score: z.number(), | ||
| highestTile: z.number(), | ||
| grid: z.array(z.array(z.number())), | ||
| }), | ||
| ); | ||
| const transposedGrid = gameState.grid[0].map((_, colIndex) => | ||
| gameState.grid.map((row) => row[colIndex]), | ||
| ); | ||
| const grid = transposedGrid.map((row, rowIndex) => ({ | ||
| [`row${rowIndex + 1}`]: row, | ||
| })); | ||
| console.log("Game State:", { | ||
| score: gameState.score, | ||
| highestTile: gameState.highestTile, | ||
| grid: grid, | ||
| }); | ||
| if (isGameOver) { | ||
| console.log("🏁 Game Over!"); | ||
| return; | ||
| } | ||
| // Analyze board and decide next move | ||
| const analysis = await stagehand.extract( | ||
| `Based on the current game state: | ||
| - Score: ${gameState.score} | ||
| - Highest tile: ${gameState.highestTile} | ||
| - Grid: This is a 4x4 matrix ordered by row (top to bottom) and column (left to right). The rows are stacked vertically, and tiles can move vertically between rows or horizontally between columns:\n${grid | ||
| .map((row) => { | ||
| const rowName = Object.keys(row)[0]; | ||
| return ` ${rowName}: ${row[rowName].join(", ")}`; | ||
| }) | ||
| .join("\n")} | ||
| What is the best move (up/down/left/right)? Consider: | ||
| 1. Keeping high value tiles in corners (bottom left, bottom right, top left, top right) | ||
| 2. Maintaining a clear path to merge tiles | ||
| 3. Avoiding moves that could block merges | ||
| 4. Only adjacent tiles of the same value can merge | ||
| 5. Making a move will move all tiles in that direction until they hit a tile of a different value or the edge of the board | ||
| 6. Tiles cannot move past the edge of the board | ||
| 7. Each move must move at least one tile`, | ||
| z.object({ | ||
| move: z.enum(["up", "down", "left", "right"]), | ||
| confidence: z.number(), | ||
| reasoning: z.string(), | ||
| }), | ||
| ); | ||
| console.log("Move Analysis:", analysis); | ||
| const moveKey = { | ||
| up: "ArrowUp", | ||
| down: "ArrowDown", | ||
| left: "ArrowLeft", | ||
| right: "ArrowRight", | ||
| }[analysis.move]; | ||
| await page.keyPress(moveKey); | ||
| console.log("🎯 Executed move:", analysis.move); | ||
| } | ||
| } catch (error) { | ||
| console.error("❌ Error in game loop:", error); | ||
| const isGameOver = await page.evaluate(() => { | ||
| return document.querySelector(".game-over") !== null; | ||
| }); | ||
| if (isGameOver) { | ||
| console.log("🏁 Game Over!"); | ||
| } else { | ||
| throw error; // Re-throw non-game-over errors | ||
| } | ||
| } |
There was a problem hiding this comment.
missing finally block - stagehand.close() never called if error occurs or game ends
| try { | |
| console.log("🌐 Navigating to 2048..."); | |
| await page.goto("https://ovolve.github.io/2048-AI/"); | |
| // Main game loop | |
| while (true) { | |
| console.log("🔄 Game loop iteration..."); | |
| // Add a small delay for UI updates | |
| await new Promise((resolve) => setTimeout(resolve, 300)); | |
| // Get current game state | |
| const gameState = await stagehand.extract( | |
| `Extract the current game state: | |
| 1. Score from the score counter | |
| 2. All tile values in the 4x4 grid (empty spaces as 0) | |
| 3. Highest tile value present`, | |
| z.object({ | |
| score: z.number(), | |
| highestTile: z.number(), | |
| grid: z.array(z.array(z.number())), | |
| }), | |
| ); | |
| const transposedGrid = gameState.grid[0].map((_, colIndex) => | |
| gameState.grid.map((row) => row[colIndex]), | |
| ); | |
| const grid = transposedGrid.map((row, rowIndex) => ({ | |
| [`row${rowIndex + 1}`]: row, | |
| })); | |
| console.log("Game State:", { | |
| score: gameState.score, | |
| highestTile: gameState.highestTile, | |
| grid: grid, | |
| }); | |
| if (isGameOver) { | |
| console.log("🏁 Game Over!"); | |
| return; | |
| } | |
| // Analyze board and decide next move | |
| const analysis = await stagehand.extract( | |
| `Based on the current game state: | |
| - Score: ${gameState.score} | |
| - Highest tile: ${gameState.highestTile} | |
| - Grid: This is a 4x4 matrix ordered by row (top to bottom) and column (left to right). The rows are stacked vertically, and tiles can move vertically between rows or horizontally between columns:\n${grid | |
| .map((row) => { | |
| const rowName = Object.keys(row)[0]; | |
| return ` ${rowName}: ${row[rowName].join(", ")}`; | |
| }) | |
| .join("\n")} | |
| What is the best move (up/down/left/right)? Consider: | |
| 1. Keeping high value tiles in corners (bottom left, bottom right, top left, top right) | |
| 2. Maintaining a clear path to merge tiles | |
| 3. Avoiding moves that could block merges | |
| 4. Only adjacent tiles of the same value can merge | |
| 5. Making a move will move all tiles in that direction until they hit a tile of a different value or the edge of the board | |
| 6. Tiles cannot move past the edge of the board | |
| 7. Each move must move at least one tile`, | |
| z.object({ | |
| move: z.enum(["up", "down", "left", "right"]), | |
| confidence: z.number(), | |
| reasoning: z.string(), | |
| }), | |
| ); | |
| console.log("Move Analysis:", analysis); | |
| const moveKey = { | |
| up: "ArrowUp", | |
| down: "ArrowDown", | |
| left: "ArrowLeft", | |
| right: "ArrowRight", | |
| }[analysis.move]; | |
| await page.keyPress(moveKey); | |
| console.log("🎯 Executed move:", analysis.move); | |
| } | |
| } catch (error) { | |
| console.error("❌ Error in game loop:", error); | |
| const isGameOver = await page.evaluate(() => { | |
| return document.querySelector(".game-over") !== null; | |
| }); | |
| if (isGameOver) { | |
| console.log("🏁 Game Over!"); | |
| } else { | |
| throw error; // Re-throw non-game-over errors | |
| } | |
| } | |
| try { | |
| console.log("🌐 Navigating to 2048..."); | |
| await page.goto("https://ovolve.github.io/2048-AI/"); | |
| // Main game loop | |
| while (true) { | |
| console.log("🔄 Game loop iteration..."); | |
| // Add a small delay for UI updates | |
| await new Promise((resolve) => setTimeout(resolve, 300)); | |
| // Get current game state | |
| const gameState = await stagehand.extract( | |
| `Extract the current game state: | |
| 1. Score from the score counter | |
| 2. All tile values in the 4x4 grid (empty spaces as 0) | |
| 3. Highest tile value present`, | |
| z.object({ | |
| score: z.number(), | |
| highestTile: z.number(), | |
| grid: z.array(z.array(z.number())), | |
| }), | |
| ); | |
| const transposedGrid = gameState.grid[0].map((_, colIndex) => | |
| gameState.grid.map((row) => row[colIndex]), | |
| ); | |
| const grid = transposedGrid.map((row, rowIndex) => ({ | |
| [`row${rowIndex + 1}`]: row, | |
| })); | |
| console.log("Game State:", { | |
| score: gameState.score, | |
| highestTile: gameState.highestTile, | |
| grid: grid, | |
| }); | |
| // Analyze board and decide next move | |
| const analysis = await stagehand.extract( | |
| `Based on the current game state: | |
| - Score: ${gameState.score} | |
| - Highest tile: ${gameState.highestTile} | |
| - Grid: This is a 4x4 matrix ordered by row (top to bottom) and column (left to right). The rows are stacked vertically, and tiles can move vertically between rows or horizontally between columns:\n${grid | |
| .map((row) => { | |
| const rowName = Object.keys(row)[0]; | |
| return ` ${rowName}: ${row[rowName].join(", ")}`; | |
| }) | |
| .join("\n")} | |
| What is the best move (up/down/left/right)? Consider: | |
| 1. Keeping high value tiles in corners (bottom left, bottom right, top left, top right) | |
| 2. Maintaining a clear path to merge tiles | |
| 3. Avoiding moves that could block merges | |
| 4. Only adjacent tiles of the same value can merge | |
| 5. Making a move will move all tiles in that direction until they hit a tile of a different value or the edge of the board | |
| 6. Tiles cannot move past the edge of the board | |
| 7. Each move must move at least one tile`, | |
| z.object({ | |
| move: z.enum(["up", "down", "left", "right"]), | |
| confidence: z.number(), | |
| reasoning: z.string(), | |
| }), | |
| ); | |
| console.log("Move Analysis:", analysis); | |
| const moveKey = { | |
| up: "ArrowUp", | |
| down: "ArrowDown", | |
| left: "ArrowLeft", | |
| right: "ArrowRight", | |
| }[analysis.move]; | |
| await page.keyPress(moveKey); | |
| console.log("🎯 Executed move:", analysis.move); | |
| } | |
| } catch (error) { | |
| console.error("❌ Error in game loop:", error); | |
| const isGameOver = await page.evaluate(() => { | |
| return document.querySelector(".game-over") !== null; | |
| }); | |
| if (isGameOver) { | |
| console.log("🏁 Game Over!"); | |
| } else { | |
| throw error; // Re-throw non-game-over errors | |
| } | |
| } finally { | |
| await stagehand.close(); | |
| } |
| try { | ||
| const page = stagehand.context.pages()[0]; | ||
| await page.goto("https://amazon.com"); | ||
|
|
||
| const agentRun = await agent.execute({ | ||
| instruction: "go to amazon, and search for shampoo, stop after searching", | ||
| maxSteps: 20, | ||
| }); | ||
| // stream the text | ||
| for await (const delta of agentRun.textStream) { | ||
| process.stdout.write(delta); | ||
| } | ||
| // stream everything ( toolcalls, messages, etc.) | ||
| // for await (const delta of result.fullStream) { | ||
| // console.log(delta); | ||
| // } | ||
| // Create a streaming agent with stream: true in the config | ||
| const agent = stagehand.agent({ | ||
| model: "anthropic/claude-sonnet-4-5-20250929", | ||
| stream: true, // This makes execute() return AgentStreamResult | ||
| }); | ||
|
|
||
| const finalResult = await agentRun.result; | ||
| console.log("Final Result:", finalResult); | ||
| } catch (error) { | ||
| console.log(`${chalk.red("✗")} Error: ${error}`); | ||
| const agentRun = await agent.execute({ | ||
| instruction: "go to amazon, and search for shampoo, stop after searching", | ||
| maxSteps: 20, | ||
| }); | ||
| // stream the text | ||
| for await (const delta of agentRun.textStream) { | ||
| process.stdout.write(delta); | ||
| } | ||
| // stream everything ( toolcalls, messages, etc.) | ||
| // for await (const delta of result.fullStream) { | ||
| // console.log(delta); | ||
| // } | ||
|
|
||
| const finalResult = await agentRun.result; | ||
| console.log("Final Result:", finalResult); | ||
| } catch (error) { | ||
| console.log(`${chalk.red("✗")} Error: ${error}`); | ||
| } |
There was a problem hiding this comment.
missing finally block - stagehand.close() never called
| try { | |
| const page = stagehand.context.pages()[0]; | |
| await page.goto("https://amazon.com"); | |
| const agentRun = await agent.execute({ | |
| instruction: "go to amazon, and search for shampoo, stop after searching", | |
| maxSteps: 20, | |
| }); | |
| // stream the text | |
| for await (const delta of agentRun.textStream) { | |
| process.stdout.write(delta); | |
| } | |
| // stream everything ( toolcalls, messages, etc.) | |
| // for await (const delta of result.fullStream) { | |
| // console.log(delta); | |
| // } | |
| // Create a streaming agent with stream: true in the config | |
| const agent = stagehand.agent({ | |
| model: "anthropic/claude-sonnet-4-5-20250929", | |
| stream: true, // This makes execute() return AgentStreamResult | |
| }); | |
| const finalResult = await agentRun.result; | |
| console.log("Final Result:", finalResult); | |
| } catch (error) { | |
| console.log(`${chalk.red("✗")} Error: ${error}`); | |
| const agentRun = await agent.execute({ | |
| instruction: "go to amazon, and search for shampoo, stop after searching", | |
| maxSteps: 20, | |
| }); | |
| // stream the text | |
| for await (const delta of agentRun.textStream) { | |
| process.stdout.write(delta); | |
| } | |
| // stream everything ( toolcalls, messages, etc.) | |
| // for await (const delta of result.fullStream) { | |
| // console.log(delta); | |
| // } | |
| const finalResult = await agentRun.result; | |
| console.log("Final Result:", finalResult); | |
| } catch (error) { | |
| console.log(`${chalk.red("✗")} Error: ${error}`); | |
| } | |
| try { | |
| const page = stagehand.context.pages()[0]; | |
| await page.goto("https://amazon.com"); | |
| // Create a streaming agent with stream: true in the config | |
| const agent = stagehand.agent({ | |
| model: "anthropic/claude-sonnet-4-5-20250929", | |
| stream: true, // This makes execute() return AgentStreamResult | |
| }); | |
| const agentRun = await agent.execute({ | |
| instruction: "go to amazon, and search for shampoo, stop after searching", | |
| maxSteps: 20, | |
| }); | |
| // stream the text | |
| for await (const delta of agentRun.textStream) { | |
| process.stdout.write(delta); | |
| } | |
| // stream everything ( toolcalls, messages, etc.) | |
| // for await (const delta of result.fullStream) { | |
| // console.log(delta); | |
| // } | |
| const finalResult = await agentRun.result; | |
| console.log("Final Result:", finalResult); | |
| } catch (error) { | |
| console.log(`${chalk.red("✗")} Error: ${error}`); | |
| } finally { | |
| await stagehand.close(); | |
| } |
| const stagehand = new Stagehand({ | ||
| env: "LOCAL", | ||
| verbose: 0, | ||
| model: "openai/gpt-4.1", | ||
| }); | ||
|
|
||
| // crossing OOPIF & shadow root boundaries with deep locator | ||
| await page | ||
| .deepLocator( | ||
| "/html/body/shadow-host//section/iframe/html/body/main/section[1]/form/div/div[1]/input", | ||
| ) | ||
| .fill("nunya"); | ||
| await page | ||
| .deepLocator( | ||
| "/html/body/shadow-host//section/iframe/html/body/main/section[1]/form/div/div[2]/input", | ||
| ) | ||
| .fill("business"); | ||
| } | ||
| await stagehand.init(); | ||
|
|
||
| (async () => { | ||
| const stagehand = new Stagehand({ | ||
| env: "LOCAL", | ||
| verbose: 0, | ||
| model: "openai/gpt-4.1", | ||
| }); | ||
| await stagehand.init(); | ||
| await example(stagehand); | ||
| })(); | ||
| const page = stagehand.context.pages()[0]; | ||
| await page.goto( | ||
| "https://browserbase.github.io/stagehand-eval-sites/sites/oopif-in-closed-shadow-dom/", | ||
| ); | ||
|
|
||
| // crossing OOPIF & shadow root boundaries with deep locator | ||
| await page | ||
| .deepLocator( | ||
| "/html/body/shadow-host//section/iframe/html/body/main/section[1]/form/div/div[1]/input", | ||
| ) | ||
| .fill("nunya"); | ||
| await page | ||
| .deepLocator( | ||
| "/html/body/shadow-host//section/iframe/html/body/main/section[1]/form/div/div[2]/input", | ||
| ) | ||
| .fill("business"); |
There was a problem hiding this comment.
missing cleanup - stagehand.close() never called
Additional Comments (11)
|
why
Now that ESM is supported, examples can use top-level await directly instead of wrapping code in
(async () => { ... })()patterns.what changed
test plan
Summary by cubic
Converted example scripts to ESM with top-level await to simplify flow and reduce boilerplate. This makes the examples easier to read and follow.
Written for commit 60f1527. Summary will update on new commits. Review in cubic