diff --git a/README.md b/README.md index d268398..f3a84f6 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ Add your Browserbase API key to `.env`: BROWSERBASE_API_KEY=your_api_key_here ``` +The starter function uses [Stagehand](https://docs.stagehand.dev), which needs its extension in the browser session. Upload the extension then paste the returned `id` into `extensionId` in `index.ts`: + +```sh +browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip +``` + Start the local development server: ```sh @@ -45,6 +51,8 @@ When ready, publish to Browserbase: pnpm bb publish index.ts ``` +Then [attach your API key as a secret](#secrets) to the published function. + ## Usage ### Basic Function @@ -59,23 +67,38 @@ defineFn("hello-world", async () => { ### Browser Automation -Every function receives a `context` with a managed browser session. Connect to it with Playwright: +Every function receives a `context` with a managed browser session. Connect [Stagehand](https://docs.stagehand.dev) to it by session ID: ```ts import { defineFn } from "@browserbasehq/sdk-functions"; -import { chromium } from "playwright-core"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; -defineFn("scrape-titles", async (context) => { - const browser = await chromium.connectOverCDP(context.session.connectUrl); - const page = browser.contexts()[0]!.pages()[0]!; - - await page.goto("https://news.ycombinator.com"); - const titles = await page.$$eval(".titleline > a", (els) => - els.slice(0, 5).map((el) => el.textContent), - ); - - return { titles }; -}); +defineFn( + "scrape-titles", + async (context) => { + const browser = await browserbase.connect({ + // The local dev server has no secrets, so fall back to .env. + apiKey: + context.secrets.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY!, + sessionId: context.session.id, + }); + // In this example, Stagehand uses the Model Gateway where Browserbase charges for the tokens + const stagehand = await Stagehand.create({ browser }); + const page = (await browser.context.activePage())!; + + await page.goto("https://news.ycombinator.com"); + const { data } = await stagehand.extract( + "Extract the titles of the top 5 stories", + z.object({ titles: z.array(z.string()).max(5) }), + ); + + await stagehand.close(); + return { titles: data.titles }; + }, + // The ID from `browse cloud extensions upload`. Stagehand needs its extension in the session. + { sessionConfig: { extensionId: "your-extension-id" } }, +); ``` ### Parameter Validation @@ -106,25 +129,51 @@ Pass `sessionConfig` to customize the browser session (uses the same options as ```ts import { defineFn } from "@browserbasehq/sdk-functions"; -import { chromium } from "playwright-core"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; defineFn( "stealth-scraper", async (context) => { - const browser = await chromium.connectOverCDP(context.session.connectUrl); - const page = browser.contexts()[0]!.pages()[0]!; + const browser = await browserbase.connect({ + apiKey: + context.secrets.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY!, + sessionId: context.session.id, + }); + const stagehand = await Stagehand.create({ browser }); + const page = (await browser.context.activePage())!; await page.goto("https://example.com"); - return { content: await page.textContent("body") }; + const { data } = await stagehand.extract( + "Extract the main text of the page", + z.object({ content: z.string() }), + ); + + await stagehand.close(); + return { content: data.content }; }, { sessionConfig: { + extensionId: "your-extension-id", // Stagehand's extension browserSettings: { advancedStealth: true }, }, }, ); ``` +### Secrets + +Keep API keys in encrypted project secrets. Each secret attached to a function is available as `context.secrets[name]`. The Stagehand examples read `BROWSERBASE_API_KEY` this way. They don't need a model API key: without a `model` option, Stagehand uses the [Browserbase Model Gateway](https://docs.stagehand.dev/v4/configuration/models#model-gateway), which picks a model for each call. Browserbase charges for the tokens. + +Create the secret and attach it to the published function with the [`browse` CLI](https://www.npmjs.com/package/browse): + +```sh +browse cloud secrets create BROWSERBASE_API_KEY --env BROWSERBASE_API_KEY +browse functions secrets attach +``` + +Use the Function ID from `builtFunctions[].id` in the publish output. The local development server doesn't pass secrets, so the examples fall back to the values in `.env`. + ## CLI Reference The `bb` CLI is included with the package. @@ -187,7 +236,7 @@ Options: ## Configuration -Set your Browserbase API key as an environment variable or in a `.env` file: +Set variables in your environment or in a `.env` file for the CLI and the local development server. Deployed functions read them from [secrets](#secrets) instead. | Variable | Required | Description | | --------------------- | -------- | ------------------------ | diff --git a/src/cli/init/index.ts b/src/cli/init/index.ts index 4e3373b..6e0d20e 100644 --- a/src/cli/init/index.ts +++ b/src/cli/init/index.ts @@ -73,6 +73,9 @@ export async function init(options: InitOptions) { // Step 5: Detect and update package manager const packageManager = detectPackageManager(options.packageManager); updatePackageManager(targetDir, packageManager); + if (packageManager === "pnpm") { + createPnpmWorkspaceFile(targetDir); + } // Step 6: Install dependencies console.log(chalk.gray("Installing dependencies...")); @@ -107,21 +110,37 @@ export async function init(options: InitOptions) { console.log(chalk.cyan("Next steps:")); console.log(chalk.gray("1. Navigate to your project:")); console.log(chalk.white(` cd ${options.projectName}`)); + console.log(chalk.gray("2. Add your Browserbase API key to .env")); console.log( - chalk.gray("2. Add your Browserbase API key and project ID to .env"), + chalk.gray( + "3. Upload the Stagehand extension, then paste its ID into index.ts:", + ), ); - console.log(chalk.gray("3. Run your function locally:")); + console.log( + chalk.white( + " browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip", + ), + ); + console.log(chalk.gray("4. Run your function locally:")); console.log( chalk.white( ` ${packageManager === "pnpm" ? "pnpm" : "npx"} bb dev index.ts`, ), ); - console.log(chalk.gray("4. When ready, publish your function:")); + console.log(chalk.gray("5. When ready, publish your function:")); console.log( chalk.white( ` ${packageManager === "pnpm" ? "pnpm" : "npx"} bb publish index.ts`, ), ); + console.log( + chalk.gray( + "6. Create a BROWSERBASE_API_KEY project secret and attach it to the function:", + ), + ); + console.log( + chalk.white(" browse functions secrets attach "), + ); console.log(""); console.log(chalk.gray("Learn more at https://browserbase.com/docs")); } catch (error) { @@ -192,6 +211,9 @@ function updatePackageManager( // Add "type": "module" to support ES modules packageJson.type = "module"; + // pnpm 11 init adds devEngines.packageManager. npm rejects it, and it conflicts with packageManager. + delete packageJson.devEngines; + writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); console.log( chalk.green(`✓ Package manager set to ${packageJson.packageManager}`), @@ -208,29 +230,77 @@ function installDependencies( // Install regular dependencies console.log(chalk.gray(" Installing @browserbasehq/sdk-functions...")); - execSync(`${installCmd} @browserbasehq/sdk-functions`, { - cwd: targetDir, - stdio: "pipe", - }); + runInstallCommand(`${installCmd} @browserbasehq/sdk-functions`, targetDir); - console.log(chalk.gray(" Installing playwright-core...")); - execSync(`${installCmd} playwright-core`, { - cwd: targetDir, - stdio: "pipe", - }); + console.log(chalk.gray(" Installing @browserbasehq/stagehand...")); + runInstallCommand(`${installCmd} @browserbasehq/stagehand`, targetDir); + // Match Stagehand's version of zod in order to pass type checks. + const zodVersion = readStagehandZodVersion(targetDir); console.log(chalk.gray(" Installing zod...")); - execSync(`${installCmd} zod`, { - cwd: targetDir, - stdio: "pipe", - }); + runInstallCommand( + `${installCmd} ${zodVersion ? `zod@${zodVersion}` : "zod"}`, + targetDir, + ); // Install dev dependencies console.log(chalk.gray(" Installing TypeScript and type definitions...")); - execSync(`${installDevCmd} typescript @types/node`, { - cwd: targetDir, - stdio: "pipe", - }); + runInstallCommand(`${installDevCmd} typescript @types/node`, targetDir); +} + +function readStagehandZodVersion(targetDir: string): string | undefined { + try { + const stagehandPackageJson = JSON.parse( + readFileSync( + join( + targetDir, + "node_modules", + "@browserbasehq", + "stagehand", + "package.json", + ), + "utf-8", + ), + ); + const version = stagehandPackageJson.dependencies?.zod; + return typeof version === "string" ? version : undefined; + } catch { + return undefined; + } +} + +function runInstallCommand(command: string, cwd: string) { + try { + execSync(command, { cwd, stdio: "pipe" }); + } catch (error) { + // execSync's message has only stderr. The package manager often writes the real error to stdout. + const { stdout, stderr } = error as { stdout?: Buffer; stderr?: Buffer }; + const output = [stdout, stderr] + .map((stream) => stream?.toString().trim()) + .filter(Boolean) + .join("\n"); + throw new Error( + `Command failed: ${command}${output ? `\n${output}` : ""}`, + { + cause: error, + }, + ); + } +} + +function createPnpmWorkspaceFile(targetDir: string) { + const workspacePath = join(targetDir, "pnpm-workspace.yaml"); + if (!existsSync(workspacePath)) { + const templatePath = join( + __dirname, + "templates", + "pnpm-workspace.yaml.template", + ); + copyFileSync(templatePath, workspacePath); + console.log(chalk.green("✓ pnpm-workspace.yaml file created")); + } else { + console.log(chalk.yellow("✓ pnpm-workspace.yaml file already exists")); + } } function createEnvFile(targetDir: string) { diff --git a/src/cli/init/templates/.env.template b/src/cli/init/templates/.env.template index e7e6194..9d36687 100644 --- a/src/cli/init/templates/.env.template +++ b/src/cli/init/templates/.env.template @@ -1,5 +1,4 @@ # Browserbase Configuration -# Get your API key from https://browserbase.com +# Get your API key from https://browserbase.com/settings -# Your Browserbase API key BROWSERBASE_API_KEY=your_api_key_here diff --git a/src/cli/init/templates/pnpm-workspace.yaml.template b/src/cli/init/templates/pnpm-workspace.yaml.template new file mode 100644 index 0000000..8a445d5 --- /dev/null +++ b/src/cli/init/templates/pnpm-workspace.yaml.template @@ -0,0 +1,3 @@ +# pnpm 11 and later fail installs when esbuild's build script is not approved. +allowBuilds: + esbuild: true diff --git a/src/cli/init/templates/starter-function.ts.template b/src/cli/init/templates/starter-function.ts.template index fd73548..b7c25b8 100644 --- a/src/cli/init/templates/starter-function.ts.template +++ b/src/cli/init/templates/starter-function.ts.template @@ -1,61 +1,44 @@ import { defineFn } from "@browserbasehq/sdk-functions"; -import { chromium } from "playwright-core"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; // This is your first Browserbase function! // You can run it locally with: bb dev index.ts // Once ready, publish it with: bb publish index.ts -type HNSubmission = { - title: string | null; - url: string | null; - rank: number; -}; - -defineFn("my-function", async (context) => { - const { session } = context; - - console.log("Connecting to browser session:", session.id); - - // Connect to the browser instance - const browser = await chromium.connectOverCDP(session.connectUrl); - const browserContext = browser.contexts()[0]!; - const page = browserContext.pages()[0]!; - - // Navigate to Hacker News - console.log("Navigating to Hacker News..."); - await page.goto("https://news.ycombinator.com"); - - // Wait for the content to load - await page.waitForSelector(".athing", { timeout: 30000 }); - - // Extract the first three submission titles - const titles = await page.evaluate(() => { - const results: HNSubmission[] = []; - - document.querySelectorAll(".athing").forEach((submission, idx) => { - if (idx >= 3) return; // only return 3 - - const titleElement = submission.querySelector(".titleline > a"); - - if (titleElement) { - results.push({ - title: titleElement.textContent ?? null, - url: titleElement.getAttribute("href"), - rank: idx + 1, - }); - } +defineFn( + "my-function", + async (context) => { + const browser = await browserbase.connect({ + // The local dev server has no secrets, so fall back to .env. + apiKey: + context.secrets.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY!, + sessionId: context.session.id, }); - - return results; - }); - - console.log(`Successfully extracted ${titles.length} titles`); - - // Return the results - return { - message: "Successfully fetched top Hacker News stories", - timestamp: new Date().toISOString(), - results: titles, - }; -}); - + // In this example, Stagehand uses the Model Gateway where Browserbase charges for the tokens + const stagehand = await Stagehand.create({ browser }); + const page = (await browser.context.activePage())!; + + await page.goto("https://news.ycombinator.com"); + const { data } = await stagehand.extract( + "Extract the top 3 stories with their rank, title, and link URL.", + z.object({ + stories: z + .array(z.object({ rank: z.number(), title: z.string(), url: z.string() })) + .max(3), + }), + ); + + await stagehand.close(); + return { + message: "Successfully fetched top Hacker News stories", + timestamp: new Date().toISOString(), + results: data.stories, + }; + }, + { + // Upload the Stagehand extension once, then paste its ID here: + // browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip + sessionConfig: { extensionId: "your-extension-id" }, + }, +); diff --git a/tests/integration/cli/init.test.ts b/tests/integration/cli/init.test.ts index c0860b7..13ebf0b 100644 --- a/tests/integration/cli/init.test.ts +++ b/tests/integration/cli/init.test.ts @@ -42,6 +42,17 @@ describe("Init Command", () => { existsSync(join(projectDir, "index.ts")), "index.ts should exist", ); + const starter = readFileSync(join(projectDir, "index.ts"), "utf-8"); + assert.doesNotMatch( + starter, + /await browser\.close\(\)/, + "index.ts should not release the Function's session with browser.close()", + ); + assert.match( + starter, + /Stagehand\.create\(\{ browser \}\)/, + "index.ts should omit the model so the Model Gateway picks one", + ); assert.ok(existsSync(join(projectDir, ".env")), ".env should exist"); assert.ok( existsSync(join(projectDir, ".gitignore")), @@ -55,6 +66,11 @@ describe("Init Command", () => { existsSync(join(projectDir, ".git")), ".git directory should exist", ); + assert.match( + readFileSync(join(projectDir, "pnpm-workspace.yaml"), "utf-8"), + /allowBuilds:\n {2}esbuild: true/, + "pnpm-workspace.yaml should allow the esbuild build script", + ); }); it("package.json has correct contents", () => { @@ -68,6 +84,11 @@ describe("Init Command", () => { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); assert.equal(pkg.type, "module", 'Should have "type": "module"'); + assert.equal( + pkg.devEngines, + undefined, + "Should not have devEngines, because npm rejects devEngines.packageManager set to pnpm", + ); // Check dependencies include expected packages const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; @@ -75,8 +96,30 @@ describe("Init Command", () => { allDeps["@browserbasehq/sdk-functions"], "Should depend on @browserbasehq/sdk-functions", ); - assert.ok(allDeps["playwright-core"], "Should depend on playwright-core"); + assert.ok( + allDeps["@browserbasehq/stagehand"], + "Should depend on @browserbasehq/stagehand", + ); + + // zod must match Stagehand's version, so schemas passed to Stagehand type-check. + const stagehandPkg = JSON.parse( + readFileSync( + join( + dir, + projectName, + "node_modules", + "@browserbasehq", + "stagehand", + "package.json", + ), + "utf-8", + ), + ); assert.ok(allDeps["zod"], "Should depend on zod"); + assert.ok( + String(allDeps["zod"]).includes(stagehandPkg.dependencies.zod), + `zod (${allDeps["zod"]}) should match Stagehand's zod (${stagehandPkg.dependencies.zod})`, + ); }); it("rejects invalid project names", () => {