diff --git a/.changeset/warm-functions-parity.md b/.changeset/warm-functions-parity.md new file mode 100644 index 000000000..a29162f9d --- /dev/null +++ b/.changeset/warm-functions-parity.md @@ -0,0 +1,5 @@ +--- +"browse": patch +--- + +Align `browse functions` with the Browserbase Functions SDK by supporting optional project overrides, matching local invocation context and error payloads, and accepting the SDK's `--api-url` option. diff --git a/packages/cli/README.md b/packages/cli/README.md index 0e694c43c..a1daeea56 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -256,6 +256,10 @@ browse functions invoke --params '{"url":"https://example.com"}' browse functions invoke --check-status ``` +Set `BROWSERBASE_API_KEY` for Functions commands. Browserbase infers the project +from the key; `--project-id` remains available as an explicit override, and +`--api-url` is accepted as an alias for `--base-url`. + ## Templates Discover and scaffold ready-to-run Browserbase example projects. diff --git a/packages/cli/skills/browse/SKILL.md b/packages/cli/skills/browse/SKILL.md index 9ddf70137..2f7d05771 100644 --- a/packages/cli/skills/browse/SKILL.md +++ b/packages/cli/skills/browse/SKILL.md @@ -277,7 +277,7 @@ browse functions invoke --params '{"url":"https://example.com"}' browse functions invoke --check-status ``` -Functions commands use `BROWSERBASE_API_KEY`. Generated projects import `defineFn` from `@browserbasehq/sdk-functions`. +Functions commands use `BROWSERBASE_API_KEY`, and Browserbase infers the project from the key. Generated projects import `defineFn` from `@browserbasehq/sdk-functions`. ## Templates diff --git a/packages/cli/src/commands/functions/dev.ts b/packages/cli/src/commands/functions/dev.ts index 19a6983ca..4cdfa0145 100644 --- a/packages/cli/src/commands/functions/dev.ts +++ b/packages/cli/src/commands/functions/dev.ts @@ -24,6 +24,8 @@ export default class FunctionsDev extends BrowseCommand { helpValue: "", }), "base-url": Flags.string({ + aliases: ["api-url"], + char: "u", description: "Override the Browserbase API base URL.", helpValue: "", }), @@ -37,6 +39,10 @@ export default class FunctionsDev extends BrowseCommand { description: "Port to listen on.", helpValue: "", }), + "project-id": Flags.string({ + description: "Browserbase project ID used for local browser sessions.", + helpValue: "", + }), verbose: Flags.boolean({ description: "Print verbose runtime logs.", }), @@ -50,6 +56,7 @@ export default class FunctionsDev extends BrowseCommand { entrypoint: args.entrypoint, host: flags.host, port: flags.port, + projectId: flags["project-id"], verbose: flags.verbose ?? false, }); } diff --git a/packages/cli/src/commands/functions/invoke.ts b/packages/cli/src/commands/functions/invoke.ts index ade22822a..f45e32fec 100644 --- a/packages/cli/src/commands/functions/invoke.ts +++ b/packages/cli/src/commands/functions/invoke.ts @@ -26,6 +26,8 @@ export default class FunctionsInvoke extends BrowseCommand { helpValue: "", }), "base-url": Flags.string({ + aliases: ["api-url"], + char: "u", description: "Override the Browserbase API base URL.", helpValue: "", }), diff --git a/packages/cli/src/commands/functions/publish.ts b/packages/cli/src/commands/functions/publish.ts index ceea0f365..97d9112fe 100644 --- a/packages/cli/src/commands/functions/publish.ts +++ b/packages/cli/src/commands/functions/publish.ts @@ -24,12 +24,18 @@ export default class FunctionsPublish extends BrowseCommand { helpValue: "", }), "base-url": Flags.string({ + aliases: ["api-url"], + char: "u", description: "Override the Browserbase API base URL.", helpValue: "", }), "dry-run": Flags.boolean({ description: "Show what would be published without uploading.", }), + "project-id": Flags.string({ + description: "Browserbase project ID to publish into.", + helpValue: "", + }), }; async run(): Promise { @@ -39,6 +45,7 @@ export default class FunctionsPublish extends BrowseCommand { baseUrl: flags["base-url"], dryRun: flags["dry-run"] ?? false, entrypoint: args.entrypoint, + projectId: flags["project-id"], }); } } diff --git a/packages/cli/src/lib/functions/dev.ts b/packages/cli/src/lib/functions/dev.ts index 95aac905e..e1811625d 100644 --- a/packages/cli/src/lib/functions/dev.ts +++ b/packages/cli/src/lib/functions/dev.ts @@ -10,8 +10,8 @@ import { fail } from "../errors.js"; import { functionsRequest, resolveEntrypoint, - resolveFunctionsApiConfig, - type FunctionsApiConfig, + resolveFunctionsProjectConfig, + type FunctionsProjectConfig, } from "./shared.js"; const DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS = 10_000; @@ -22,10 +22,15 @@ export interface StartFunctionsDevServerOptions { entrypoint: string; host: string; port: number; + projectId?: string; verbose: boolean; } interface InvocationContext { + invocation: { + id: string; + region: "local"; + }; session: { id: string; connectUrl: string; @@ -105,7 +110,13 @@ class InvocationBridge { sendJson( this.invokeConnection.response, 500, - { error: payload }, + { + error: { + message: payload.errorMessage, + stackTrace: payload.stackTrace, + type: payload.errorType, + }, + }, this.invokeConnection.corsHeaders, ); try { @@ -139,7 +150,7 @@ class InvocationBridge { "content-type": "application/json", "Lambda-Runtime-Aws-Request-Id": requestId, "Lambda-Runtime-Deadline-Ms": String(Date.now() + 300_000), - "Lambda-Runtime-Invoked-Function-Arn": `arn:aws:lambda:local:function:${functionName}`, + "Lambda-Runtime-Invoked-Function-Arn": `arn:aws:lambda:us-east-1:000000000000:function:${functionName}`, }); this.nextConnection.response.end( JSON.stringify({ @@ -171,7 +182,7 @@ class InvocationBridge { } class BrowserSessionManager { - constructor(private readonly config: FunctionsApiConfig) {} + constructor(private readonly config: FunctionsProjectConfig) {} async createSession( sessionConfig: Record = {}, @@ -181,7 +192,10 @@ class BrowserSessionManager { headers: { "content-type": "application/json", }, - body: JSON.stringify(sessionConfig), + body: JSON.stringify({ + projectId: this.config.projectId, + ...sessionConfig, + }), }); const session = (await response.json()) as { id?: string; @@ -202,7 +216,10 @@ class BrowserSessionManager { headers: { "content-type": "application/json", }, - body: JSON.stringify({ status: "REQUEST_RELEASE" }), + body: JSON.stringify({ + projectId: this.config.projectId, + status: "REQUEST_RELEASE", + }), }); } } @@ -330,7 +347,7 @@ export async function startFunctionsDevServer( fail("Port must be an integer between 1 and 65535."); } - const config = resolveFunctionsApiConfig(options); + const config = resolveFunctionsProjectConfig(options); const runtimeApi = `${options.host}:${options.port}`; const bridge = new InvocationBridge(); const sessionManager = new BrowserSessionManager(config); @@ -538,7 +555,13 @@ async function routeRequest( const accepted = bridge.triggerInvocation( functionName, params, - { session }, + { + invocation: { + id: randomUUID(), + region: "local", + }, + session, + }, corsHeaders, response, ); @@ -606,11 +629,12 @@ async function routeRequest( ); return; } - const completed = await bridge.completeWithError(requestId, { + const runtimeError = { errorMessage: payload?.errorMessage || "Unknown runtime error", errorType: payload?.errorType || "RuntimeError", stackTrace: Array.isArray(payload?.stackTrace) ? payload.stackTrace : [], - }); + }; + const completed = await bridge.completeWithError(requestId, runtimeError); sendJson( response, completed ? 202 : 400, diff --git a/packages/cli/src/lib/functions/init.ts b/packages/cli/src/lib/functions/init.ts index e772fc5d1..70eb4f0d2 100644 --- a/packages/cli/src/lib/functions/init.ts +++ b/packages/cli/src/lib/functions/init.ts @@ -68,7 +68,7 @@ export async function initFunctionsProject({ ); } - ensureCommand(packageManager); + const packageManagerVersion = ensureCommand(packageManager); const projectRoot = resolve(projectName); if (existsSync(projectRoot)) { @@ -79,7 +79,9 @@ export async function initFunctionsProject({ const packageJson = { name: projectName, + version: "1.0.0", private: true, + packageManager: `${packageManager}@${packageManagerVersion}`, type: "module", scripts: { dev: "browse functions dev index.ts", @@ -98,7 +100,7 @@ export async function initFunctionsProject({ runPackageManager( packageManager, - [...install, "@browserbasehq/sdk-functions", "playwright-core"], + [...install, "@browserbasehq/sdk-functions", "playwright-core", "zod"], projectRoot, ); runPackageManager(packageManager, [...installDev, "typescript", "@types/node"], projectRoot); @@ -129,11 +131,15 @@ export async function initFunctionsProject({ ); } -function ensureCommand(command: string): void { - const result = spawnSync(command, ["--version"], { stdio: "ignore" }); +function ensureCommand(command: string): string { + const result = spawnSync(command, ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); if (result.error || result.status !== 0) { fail(`${command} is required but was not found on PATH.`); } + return result.stdout.trim(); } function runPackageManager(packageManager: "npm" | "pnpm", args: string[], cwd: string): void { diff --git a/packages/cli/src/lib/functions/publish.ts b/packages/cli/src/lib/functions/publish.ts index 5ed5ed560..11ebd2e99 100644 --- a/packages/cli/src/lib/functions/publish.ts +++ b/packages/cli/src/lib/functions/publish.ts @@ -21,7 +21,7 @@ import { functionsRequest, pollUntil, resolveEntrypoint, - resolveFunctionsApiConfig, + resolveFunctionsProjectConfig, } from "./shared.js"; export interface PublishFunctionOptions { @@ -29,6 +29,7 @@ export interface PublishFunctionOptions { baseUrl?: string; dryRun: boolean; entrypoint: string; + projectId?: string; } interface BuildUploadResponse { @@ -67,9 +68,11 @@ const defaultIgnorePatterns = [ ".browserbase/", ]; +const maxArchiveSizeBytes = 50 * 1024 * 1024; + export async function publishFunction(options: PublishFunctionOptions): Promise { const entrypoint = await resolveEntrypoint(options.entrypoint); - const config = resolveFunctionsApiConfig(options); + const config = resolveFunctionsProjectConfig(options); const entrypointPath = relative(process.cwd(), entrypoint); if (options.dryRun) { @@ -82,6 +85,7 @@ export async function publishFunction(options: PublishFunctionOptions): Promise< dryRun: true, entrypoint: entrypointPath, files: entries, + projectId: config.projectId, }, null, 2, @@ -92,8 +96,18 @@ export async function publishFunction(options: PublishFunctionOptions): Promise< const { archivePath } = await createArchive(process.cwd()); try { + const archiveStats = await stat(archivePath); + if (archiveStats.size > maxArchiveSizeBytes) { + fail( + `Functions archive is ${(archiveStats.size / 1024 / 1024).toFixed(2)} MB; the maximum is 50 MB. Add files to .gitignore to reduce its size.`, + ); + } + const formData = new FormData(); - formData.append("metadata", JSON.stringify({ entrypoint: entrypointPath })); + formData.append( + "metadata", + JSON.stringify({ entrypoint: entrypointPath, projectId: config.projectId }), + ); formData.append( "archive", new Blob([await readFile(archivePath)], { type: "application/gzip" }), diff --git a/packages/cli/src/lib/functions/shared.ts b/packages/cli/src/lib/functions/shared.ts index 624d4c4c1..caa53a2fe 100644 --- a/packages/cli/src/lib/functions/shared.ts +++ b/packages/cli/src/lib/functions/shared.ts @@ -12,6 +12,10 @@ export interface FunctionsApiConfig { baseUrl: string; } +export interface FunctionsProjectConfig extends FunctionsApiConfig { + projectId?: string; +} + export interface PollOptions { done: (value: T) => boolean; intervalMs?: number; @@ -32,6 +36,16 @@ export function resolveFunctionsApiConfig(args: { }; } +export function resolveFunctionsProjectConfig(args: { + apiKey?: string; + baseUrl?: string; + projectId?: string; +}): FunctionsProjectConfig { + const apiConfig = resolveFunctionsApiConfig(args); + const projectId = args.projectId ?? process.env.BROWSERBASE_PROJECT_ID; + return projectId ? { ...apiConfig, projectId } : apiConfig; +} + export async function functionsRequest( config: FunctionsApiConfig, path: string, diff --git a/packages/cli/tests/cli-functions-contract.test.ts b/packages/cli/tests/cli-functions-contract.test.ts index 75fabf1bd..d963dad88 100644 --- a/packages/cli/tests/cli-functions-contract.test.ts +++ b/packages/cli/tests/cli-functions-contract.test.ts @@ -66,7 +66,17 @@ describe("functions API contracts", () => { }, async ({ baseUrl, requests }) => { const result = await runCli( - ["functions", "publish", "index.ts", "--api-key", "test-key", "--base-url", baseUrl], + [ + "functions", + "publish", + "index.ts", + "--api-key", + "test-key", + "--project-id", + "test-project", + "--api-url", + baseUrl, + ], { cwd }, ); @@ -78,6 +88,7 @@ describe("functions API contracts", () => { expectRequest(requests[0], "POST", "/v1/functions/builds", "test-key"); expect(requests[0]?.headers["content-type"]).toContain("multipart/form-data"); expect(requests[0]?.bodyText).toContain('"entrypoint":"index.ts"'); + expect(requests[0]?.bodyText).toContain('"projectId":"test-project"'); expectRequest(requests[1], "GET", "/v1/functions/builds/build_123", "test-key"); }, ); @@ -95,7 +106,16 @@ describe("functions API contracts", () => { await writeFile(join(cwd, ".browserbase", "functions", "manifests", "local.json"), "{}"); const result = await runCli( - ["functions", "publish", "index.ts", "--dry-run", "--api-key", "test-key"], + [ + "functions", + "publish", + "index.ts", + "--dry-run", + "--api-key", + "test-key", + "--project-id", + "test-project", + ], { cwd, env: { @@ -109,9 +129,11 @@ describe("functions API contracts", () => { dryRun: boolean; entrypoint: string; files: string[]; + projectId: string; }; expect(output.dryRun).toBe(true); expect(output.entrypoint).toBe("index.ts"); + expect(output.projectId).toBe("test-project"); expect(output.files).toContain("index.ts"); expect(output.files).toContain("package.json"); expect(output.files.some((file) => file.startsWith(".browserbase/"))).toBe(false); @@ -134,7 +156,17 @@ describe("functions API contracts", () => { }, async ({ baseUrl }) => { const result = await runCli( - ["functions", "publish", "index.ts", "--api-key", "test-key", "--base-url", baseUrl], + [ + "functions", + "publish", + "index.ts", + "--api-key", + "test-key", + "--project-id", + "test-project", + "--base-url", + baseUrl, + ], { cwd }, ); @@ -147,6 +179,22 @@ describe("functions API contracts", () => { ); }); + it("infers the project when no project ID is provided", async () => { + const cwd = await createFunctionFixture("functions-missing-project-"); + const result = await runCli( + ["functions", "publish", "index.ts", "--dry-run", "--api-key", "test-key"], + { + cwd, + env: { + BROWSERBASE_PROJECT_ID: "", + }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).not.toHaveProperty("projectId"); + }); + it("invokes a deployed Function and polls invocation status", async () => { await withServer( async (request, response) => { @@ -180,7 +228,7 @@ describe("functions API contracts", () => { '{"url":"https://example.com"}', "--api-key", "test-key", - "--base-url", + "--api-url", baseUrl, ]); @@ -263,9 +311,17 @@ describe("functions scaffolding and local dev", () => { const entrypoint = await readFile(join(cwd, "demo-function", "index.ts"), "utf8"); expect(entrypoint).toContain('import { defineFn } from "@browserbasehq/sdk-functions";'); - expect(await readFile(join(cwd, "demo-function", ".env"), "utf8")).toContain( - "BROWSERBASE_API_KEY=", - ); + const packageJson = JSON.parse( + await readFile(join(cwd, "demo-function", "package.json"), "utf8"), + ) as { + packageManager?: string; + version?: string; + }; + expect(packageJson.packageManager).toBe("pnpm@10.0.0"); + expect(packageJson.version).toBe("1.0.0"); + const envFile = await readFile(join(cwd, "demo-function", ".env"), "utf8"); + expect(envFile).toContain("BROWSERBASE_API_KEY="); + expect(envFile).not.toContain("BROWSERBASE_PROJECT_ID="); }); it("runs a local dev server and invokes a function", async () => { @@ -305,6 +361,8 @@ describe("functions scaffolding and local dev", () => { String(port), "--api-key", "test-key", + "--project-id", + "test-project", "--base-url", baseUrl, ], @@ -352,12 +410,21 @@ describe("functions scaffolding and local dev", () => { await expect(invokeResponse.json()).resolves.toMatchObject({ ok: true, params: { answer: 42 }, + invocation: { + id: expect.any(String), + region: "local", + }, sessionId: "sess_123", }); await waitForRequests(requests, 2); expectRequest(requests[0], "POST", "/v1/sessions", "test-key"); + expect(requests[0]?.jsonBody).toMatchObject({ projectId: "test-project" }); expectRequest(requests[1], "POST", "/v1/sessions/sess_123", "test-key"); + expect(requests[1]?.jsonBody).toMatchObject({ + projectId: "test-project", + status: "REQUEST_RELEASE", + }); }, ); }, 30_000); @@ -398,6 +465,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -471,6 +539,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", BROWSERBASE_FUNCTIONS_DEV_STARTUP_TIMEOUT_MS: "0", NODE_ENV: "test", }, @@ -519,6 +588,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -591,6 +661,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -666,6 +737,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -680,7 +752,8 @@ describe("functions scaffolding and local dev", () => { expect(first.headers.get("access-control-allow-origin")).toBeNull(); await expect(first.json()).resolves.toMatchObject({ error: { - errorMessage: expect.stringContaining("Invalid runtime response payload"), + message: expect.stringContaining("Invalid runtime response payload"), + type: "RuntimeResponseError", }, }); await waitForFileText(runtimeStatusLog, "400\n"); @@ -689,7 +762,8 @@ describe("functions scaffolding and local dev", () => { expect(second.status).toBe(500); await expect(second.json()).resolves.toMatchObject({ error: { - errorMessage: expect.stringContaining("Invalid runtime response payload"), + message: expect.stringContaining("Invalid runtime response payload"), + type: "RuntimeResponseError", }, }); await waitForFileText(runtimeStatusLog, "400\n400\n"); @@ -725,6 +799,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -767,7 +842,7 @@ async function createTempDir(prefix: string): Promise { async function createFakePackageManagerBin( name = "pnpm", - contents = "#!/bin/sh\nexit 0\n", + contents = "#!/bin/sh\necho 10.0.0\n", ): Promise { const directory = await createTempDir("functions-fake-bin-"); const scriptPath = join(directory, name); @@ -820,6 +895,7 @@ while (true) { headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, + invocation: event.context.invocation, params: event.params, sessionId: event.context.session.id, }),