From 8a2405ab46935fc4d32a67217ba6bc200ff44aee Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 17:48:22 +0000 Subject: [PATCH] refactor(cli): share the Functions SDK core Replace Browse's parallel Functions implementation with @browserbasehq/sdk-functions/core adapters while preserving Browse command flags, output, telemetry, and error mapping. --- .changeset/calm-functions-core.md | 5 + packages/cli/package.json | 5 +- packages/cli/src/commands/functions/dev.ts | 7 + packages/cli/src/commands/functions/invoke.ts | 2 + .../cli/src/commands/functions/publish.ts | 7 + packages/cli/src/lib/functions/dev.ts | 795 +----------------- packages/cli/src/lib/functions/init.ts | 155 +--- packages/cli/src/lib/functions/invoke.ts | 93 +- packages/cli/src/lib/functions/publish.ts | 291 +------ packages/cli/src/lib/functions/shared.ts | 203 ++--- .../cli/tests/cli-functions-contract.test.ts | 91 +- pnpm-lock.yaml | 59 +- pnpm-workspace.yaml | 4 + 13 files changed, 327 insertions(+), 1390 deletions(-) create mode 100644 .changeset/calm-functions-core.md diff --git a/.changeset/calm-functions-core.md b/.changeset/calm-functions-core.md new file mode 100644 index 0000000000..a81a93429a --- /dev/null +++ b/.changeset/calm-functions-core.md @@ -0,0 +1,5 @@ +--- +"browse": patch +--- + +Use the shared `@browserbasehq/sdk-functions/core` implementation for Functions scaffolding, local development, publishing, and invocation. diff --git a/packages/cli/package.json b/packages/cli/package.json index f5c0503954..d5c63bdb8f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -103,24 +103,21 @@ }, "dependencies": { "@browserbasehq/sdk": "^2.17.0", + "@browserbasehq/sdk-functions": "catalog:", "@browserbasehq/stagehand": "workspace:*", "@oclif/core": "^4.11.0", "@vercel/detect-agent": "^1.2.3", - "archiver": "^7.0.1", "deepmerge": "^4.3.1", "dotenv": "^16.5.0", "fastest-levenshtein": "^1.0.16", "http-status-codes": "^2.3.0", - "ignore": "^7.0.5", "node-html-markdown": "^1.3.0", "semver": "^7.7.4", - "tsx": "^4.20.6", "ws": "^8.18.3", "zod": "^4.2.1" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/archiver": "^6.0.3", "@types/node": "^20.11.30", "@types/semver": "^7.7.1", "@types/ws": "^8.18.1", diff --git a/packages/cli/src/commands/functions/dev.ts b/packages/cli/src/commands/functions/dev.ts index 129832a08b..36dc71e220 100644 --- a/packages/cli/src/commands/functions/dev.ts +++ b/packages/cli/src/commands/functions/dev.ts @@ -25,6 +25,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: "", }), @@ -38,6 +40,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.", }), @@ -51,6 +57,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 3d33a03685..6d809f4e64 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 9c3cd6de5a..61a06e4b96 100644 --- a/packages/cli/src/commands/functions/publish.ts +++ b/packages/cli/src/commands/functions/publish.ts @@ -25,12 +25,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 { @@ -40,6 +46,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 249f2815dc..16f816a24a 100644 --- a/packages/cli/src/lib/functions/dev.ts +++ b/packages/cli/src/lib/functions/dev.ts @@ -1,23 +1,10 @@ -import { createRequire } from "node:module"; -import { spawn } from "node:child_process"; import { - createServer, - type IncomingMessage, - type Server, - type ServerResponse, -} from "node:http"; -import { mkdir, readdir, readFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { randomUUID } from "node:crypto"; + startDevServer, + type DevServerHandle, +} from "@browserbasehq/sdk-functions/core"; import { fail } from "../errors.js"; -import { - functionsRequest, - resolveEntrypoint, - resolveFunctionsApiConfig, - type FunctionsApiConfig, -} from "./shared.js"; +import { resolveFunctionsCoreOptions, runFunctionsCore } from "./shared.js"; const DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS = 10_000; @@ -27,460 +14,60 @@ export interface StartFunctionsDevServerOptions { entrypoint: string; host: string; port: number; + projectId?: string; verbose: boolean; } -interface InvocationContext { - session: { - id: string; - connectUrl: string; - }; -} - -interface PendingConnection { - corsHeaders: Record; - response: ServerResponse; -} - -interface FunctionManifest { - name: string; - config?: { - sessionConfig?: Record; - }; -} - -class InvocationBridge { - private cleanupSessionCallback: - | ((sessionId: string) => Promise) - | null = null; - private currentRequestId: string | null = null; - private currentSessionId: string | null = null; - private invokeConnection: PendingConnection | null = null; - private nextConnection: PendingConnection | null = null; - private runtimeConnected = false; - - setCleanupSessionCallback(callback: (sessionId: string) => Promise) { - this.cleanupSessionCallback = callback; - } - - holdNextConnection( - response: ServerResponse, - corsHeaders: Record, - ) { - this.runtimeConnected = true; - if (this.nextConnection) { - this.nextConnection.response.writeHead(503, { - ...this.nextConnection.corsHeaders, - "content-type": "application/json", - }); - this.nextConnection.response.end( - JSON.stringify({ error: "Another runtime process connected." }), - ); - } - this.nextConnection = { corsHeaders, response }; - } - - isRuntimeConnected() { - return this.runtimeConnected && this.nextConnection !== null; - } - - hasActiveInvocation() { - return this.invokeConnection !== null; - } - - async completeWithSuccess(requestId: string, payload: unknown) { - if (requestId !== this.currentRequestId || !this.invokeConnection) { - return false; - } - - sendJson( - this.invokeConnection.response, - 200, - payload ?? {}, - this.invokeConnection.corsHeaders, - ); - try { - await this.cleanupSession(); - } catch (error) { - this.reportCleanupError(error); - } finally { - this.reset(); - } - return true; - } - - async completeWithError( - requestId: string, - payload: { errorMessage: string; errorType: string; stackTrace: string[] }, - ) { - if (requestId !== this.currentRequestId || !this.invokeConnection) { - return false; - } - - sendJson( - this.invokeConnection.response, - 500, - { error: payload }, - this.invokeConnection.corsHeaders, - ); - try { - await this.cleanupSession(); - } catch (error) { - this.reportCleanupError(error); - } finally { - this.reset(); - } - return true; - } - - triggerInvocation( - functionName: string, - params: Record, - context: InvocationContext, - corsHeaders: Record, - response: ServerResponse, - ): boolean { - if (!this.nextConnection || this.invokeConnection) { - return false; - } - - const requestId = randomUUID(); - this.currentRequestId = requestId; - this.currentSessionId = context.session.id; - this.invokeConnection = { corsHeaders, response }; - - this.nextConnection.response.writeHead(200, { - ...this.nextConnection.corsHeaders, - "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}`, - }); - this.nextConnection.response.end( - JSON.stringify({ - context, - functionName, - params, - }), - ); - this.nextConnection = null; - return true; - } - - private async cleanupSession() { - if (this.cleanupSessionCallback && this.currentSessionId) { - await this.cleanupSessionCallback(this.currentSessionId); - } - } - - private reportCleanupError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`Functions dev session cleanup failed: ${message}\n`); - } - - private reset() { - this.currentRequestId = null; - this.currentSessionId = null; - this.invokeConnection = null; - } -} - -class BrowserSessionManager { - constructor(private readonly config: FunctionsApiConfig) {} - - async createSession( - sessionConfig: Record = {}, - ): Promise { - const response = await functionsRequest(this.config, "/v1/sessions", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(sessionConfig), - }); - const session = (await response.json()) as { - id?: string; - connectUrl?: string; - }; - if (!session.id || !session.connectUrl) { - fail( - "Browserbase session create completed without returning id and connectUrl.", - ); - } - return { - connectUrl: session.connectUrl, - id: session.id, - }; - } - - async closeSession(sessionId: string): Promise { - await functionsRequest(this.config, `/v1/sessions/${sessionId}`, { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ status: "REQUEST_RELEASE" }), - }); - } -} - -class ManifestStore { - private readonly manifestsPath = join( - process.cwd(), - ".browserbase", - "functions", - "manifests", - ); - - private readonly manifests = new Map(); - - async load(): Promise { - this.manifests.clear(); - if (!existsSync(this.manifestsPath)) { - return; - } - - const entries = await readdir(this.manifestsPath); - for (const entry of entries) { - if (!entry.endsWith(".json")) { - continue; - } - const manifest = JSON.parse( - await readFile(join(this.manifestsPath, entry), "utf8"), - ) as FunctionManifest; - this.manifests.set(manifest.name, manifest); - } - } - - get(name: string): FunctionManifest | undefined { - return this.manifests.get(name); - } -} - -class RuntimeProcess { - private process: ReturnType | null = null; - - constructor( - private readonly entrypoint: string, - private readonly runtimeApi: string, - private readonly verbose: boolean, - ) {} - - async start() { - const require = createRequire(import.meta.url); - const tsxCli = require.resolve("tsx/cli"); - const nodeExecutable = - "bun" in process.versions ? "node" : process.execPath; - const child = spawn( - nodeExecutable, - [tsxCli, "watch", "--clear-screen=false", this.entrypoint], - { - cwd: process.cwd(), - env: { - ...process.env, - AWS_LAMBDA_RUNTIME_API: this.runtimeApi, - BB_FUNCTIONS_PHASE: "runtime", - NODE_ENV: "local", - }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - this.process = child; - - child.stdout?.on("data", (chunk) => { - const text = chunk.toString().trim(); - if (text) { - process.stderr.write(`${this.verbose ? "[runtime] " : ""}${text}\n`); - } - }); - - child.stderr?.on("data", (chunk) => { - const text = chunk.toString().trim(); - if (text) { - process.stderr.write( - `${this.verbose ? "[runtime:error] " : ""}${text}\n`, - ); - } - }); - - child.once("exit", () => { - if (this.process === child) { - this.process = null; - } - }); - - try { - await waitForChildSpawn(child); - } catch (error) { - this.process = null; - fail(`Failed to start functions runtime: ${formatErrorMessage(error)}`); - } - } - - async stop() { - const child = this.process; - if (!child) { - return; - } - - if (child.exitCode !== null || child.signalCode !== null) { - this.process = null; - return; - } - - await new Promise((resolvePromise) => { - const forceKillTimer = setTimeout(() => { - child.kill("SIGKILL"); - }, 5_000); - const finish = () => { - clearTimeout(forceKillTimer); - resolvePromise(); - }; - - child.once("exit", finish); - if (!child.kill("SIGTERM")) { - child.off("exit", finish); - finish(); - } - }); - this.process = null; - } -} - export async function startFunctionsDevServer( options: StartFunctionsDevServerOptions, ): Promise { - const entrypoint = await resolveEntrypoint(options.entrypoint); - if ( - !Number.isInteger(options.port) || - options.port < 1 || - options.port > 65_535 - ) { - fail("Port must be an integer between 1 and 65535."); - } - - const config = resolveFunctionsApiConfig(options); - const runtimeApi = `${options.host}:${options.port}`; - const bridge = new InvocationBridge(); - const sessionManager = new BrowserSessionManager(config); - const manifestStore = new ManifestStore(); - - bridge.setCleanupSessionCallback(async (sessionId) => { - await sessionManager.closeSession(sessionId); - }); - - await mkdir(join(process.cwd(), ".browserbase", "functions", "manifests"), { - recursive: true, - }); - - const server = await startServer( - options.host, - options.port, - bridge, - manifestStore, - sessionManager, + const coreOptions = resolveFunctionsCoreOptions(options); + const handle = await runFunctionsCore(() => + startDevServer({ + ...coreOptions, + entrypoint: options.entrypoint, + host: options.host, + port: options.port, + ...(options.projectId ? { projectId: options.projectId } : {}), + startupTimeoutMs: getRuntimeStartupTimeoutMs(), + verbose: options.verbose, + onLog(event) { + process.stderr.write(`${event.message}\n`); + }, + }), ); - const runtime = new RuntimeProcess(entrypoint, runtimeApi, options.verbose); - await runtime.start(); - const runtimeConnected = await waitForRuntime( - bridge, - manifestStore, - getRuntimeStartupTimeoutMs(), + console.log( + JSON.stringify( + { + ok: handle.runtimeConnected, + runtimeConnected: handle.runtimeConnected, + url: handle.url, + ...(!handle.runtimeConnected + ? { + warning: [ + "Functions runtime has not connected yet.", + "Check the runtime logs, then retry once the entrypoint is healthy.", + ].join(" "), + } + : {}), + }, + null, + 2, + ), ); - const output: { - ok: boolean; - runtimeConnected: boolean; - url: string; - warning?: string; - } = { - ok: runtimeConnected, - runtimeConnected, - url: `http://${options.host}:${options.port}`, - }; - if (!runtimeConnected) { - output.warning = [ - "Functions runtime has not connected yet.", - "Check the runtime logs, then retry once the entrypoint is healthy.", - ].join(" "); - } - console.log(JSON.stringify(output, null, 2)); - - const shutdown = async () => { - await runtime.stop(); - await new Promise((resolvePromise) => - server.close(() => resolvePromise()), - ); - }; - - process.on("SIGINT", async () => { - await shutdown(); - process.exit(0); - }); - process.on("SIGTERM", async () => { - await shutdown(); - process.exit(0); - }); + installShutdownHandler("SIGINT", handle); + installShutdownHandler("SIGTERM", handle); } -async function startServer( - host: string, - port: number, - bridge: InvocationBridge, - manifestStore: ManifestStore, - sessionManager: BrowserSessionManager, -): Promise { - const server = createServer((request, response) => { - routeRequest( - request, - response, - bridge, - manifestStore, - sessionManager, - ).catch((error) => { - handleRouteError(response, error); - }); - }); - - await new Promise((resolvePromise, reject) => { - server.listen(port, host, () => resolvePromise()); - server.on("error", reject); +function installShutdownHandler( + signal: "SIGINT" | "SIGTERM", + handle: DevServerHandle, +): void { + process.once(signal, () => { + void handle.close().then(() => process.exit(0)); }); - - return server; -} - -function handleRouteError(response: ServerResponse, error: unknown): void { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`Functions dev request failed: ${message}\n`); - - if (!response.headersSent && !response.writableEnded) { - sendJson(response, 500, { error: message }, baseCorsHeaders()); - return; - } - - if (!response.writableEnded) { - response.end(); - } -} - -async function waitForRuntime( - bridge: InvocationBridge, - manifestStore: ManifestStore, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (bridge.isRuntimeConnected()) { - await manifestStore.load(); - return true; - } - await new Promise((resolvePromise) => setTimeout(resolvePromise, 200)); - } - - await manifestStore.load(); - return bridge.isRuntimeConnected(); } function getRuntimeStartupTimeoutMs(): number { @@ -495,297 +82,5 @@ function getRuntimeStartupTimeoutMs(): number { "BROWSERBASE_FUNCTIONS_DEV_STARTUP_TIMEOUT_MS must be a non-negative number.", ); } - return parsed; } - -async function routeRequest( - request: IncomingMessage, - response: ServerResponse, - bridge: InvocationBridge, - manifestStore: ManifestStore, - sessionManager: BrowserSessionManager, -): Promise { - const method = request.method || "GET"; - const url = new URL( - request.url || "/", - `http://${request.headers.host || "127.0.0.1"}`, - ); - const path = url.pathname; - const corsHeaders = corsHeadersForRequest(request); - - if (!corsHeaders) { - sendForbiddenOrigin(response); - return; - } - - if (method === "OPTIONS") { - sendNoContent(response, 204, corsHeaders); - return; - } - - if (method === "GET" && path === "/") { - sendJson(response, 200, { ok: true }, corsHeaders); - return; - } - - if (method === "GET" && path === "/2018-06-01/runtime/invocation/next") { - bridge.holdNextConnection(response, corsHeaders); - return; - } - - const invokeMatch = path.match(/^\/v1\/functions\/([^/]+)\/invoke$/); - if (method === "POST" && invokeMatch?.[1]) { - await manifestStore.load(); - const functionName = invokeMatch[1]; - const manifest = manifestStore.get(functionName); - if (!manifest) { - sendJson( - response, - 404, - { - error: `Function "${functionName}" was not found in .browserbase/functions/manifests.`, - }, - corsHeaders, - ); - return; - } - - if (bridge.hasActiveInvocation()) { - sendJson( - response, - 503, - { error: "Another invocation is already in progress." }, - corsHeaders, - ); - return; - } - - let body; - try { - body = await readJsonBody(request); - } catch (error) { - sendJson( - response, - 400, - { - error: error instanceof Error ? error.message : "Invalid JSON body.", - }, - corsHeaders, - ); - return; - } - - const params = - body && typeof body === "object" && !Array.isArray(body) - ? (body as { params?: Record }).params || {} - : {}; - - const session = await sessionManager.createSession( - manifest.config?.sessionConfig, - ); - const accepted = bridge.triggerInvocation( - functionName, - params, - { session }, - corsHeaders, - response, - ); - - if (!accepted) { - await sessionManager.closeSession(session.id); - sendJson( - response, - 503, - { error: "No runtime is connected yet." }, - corsHeaders, - ); - } - return; - } - - const responseMatch = path.match( - /^\/2018-06-01\/runtime\/invocation\/([^/]+)\/response$/, - ); - if (method === "POST" && responseMatch?.[1]) { - const requestId = responseMatch[1]; - let payload; - try { - payload = await readJsonBody(request); - } catch (error) { - const message = `Invalid runtime response payload: ${formatErrorMessage(error)}`; - const completed = await bridge.completeWithError(requestId, { - errorMessage: message, - errorType: "RuntimeResponseError", - stackTrace: [], - }); - sendJson( - response, - 400, - completed ? { error: message } : { error: "Request ID mismatch." }, - corsHeaders, - ); - return; - } - const completed = await bridge.completeWithSuccess(requestId, payload); - sendJson( - response, - completed ? 202 : 400, - completed ? { ok: true } : { error: "Request ID mismatch." }, - corsHeaders, - ); - return; - } - - const errorMatch = path.match( - /^\/2018-06-01\/runtime\/invocation\/([^/]+)\/error$/, - ); - if (method === "POST" && errorMatch?.[1]) { - const requestId = errorMatch[1]; - let payload; - try { - payload = (await readJsonBody(request)) as { - errorMessage?: string; - errorType?: string; - stackTrace?: string[]; - }; - } catch (error) { - const message = `Invalid runtime error payload: ${formatErrorMessage(error)}`; - const completed = await bridge.completeWithError(requestId, { - errorMessage: message, - errorType: "RuntimeResponseError", - stackTrace: [], - }); - sendJson( - response, - 400, - completed ? { error: message } : { error: "Request ID mismatch." }, - corsHeaders, - ); - return; - } - const completed = await bridge.completeWithError(requestId, { - errorMessage: payload?.errorMessage || "Unknown runtime error", - errorType: payload?.errorType || "RuntimeError", - stackTrace: Array.isArray(payload?.stackTrace) ? payload.stackTrace : [], - }); - sendJson( - response, - completed ? 202 : 400, - completed ? { ok: true } : { error: "Request ID mismatch." }, - corsHeaders, - ); - return; - } - - sendJson(response, 404, { error: "Not found." }, corsHeaders); -} - -function formatErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -async function waitForChildSpawn( - child: ReturnType, -): Promise { - await new Promise((resolvePromise, reject) => { - const cleanup = () => { - child.off("error", onError); - child.off("spawn", onSpawn); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const onSpawn = () => { - cleanup(); - resolvePromise(); - }; - child.once("error", onError); - child.once("spawn", onSpawn); - }); -} - -async function readJsonBody(request: IncomingMessage): Promise { - const chunks: Uint8Array[] = []; - for await (const chunk of request) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } - - if (chunks.length === 0) { - return {}; - } - - const text = Buffer.concat(chunks).toString("utf8"); - if (!text) { - return {}; - } - - return JSON.parse(text); -} - -function sendJson( - response: ServerResponse, - statusCode: number, - body: unknown, - corsHeaders: Record, -): void { - response.writeHead(statusCode, { - ...corsHeaders, - "content-type": "application/json", - }); - response.end(JSON.stringify(body)); -} - -function sendNoContent( - response: ServerResponse, - statusCode: number, - corsHeaders: Record, -): void { - response.writeHead(statusCode, corsHeaders); - response.end(); -} - -function sendForbiddenOrigin(response: ServerResponse): void { - response.writeHead(403, { - "content-type": "application/json", - vary: "Origin", - }); - response.end(JSON.stringify({ error: "Origin is not allowed." })); -} - -function corsHeadersForRequest( - request: IncomingMessage, -): Record | null { - const origin = request.headers.origin; - if (origin === undefined) return baseCorsHeaders(); - if (Array.isArray(origin)) return null; - if (!isAllowedLoopbackOrigin(origin)) return null; - - return { - ...baseCorsHeaders(), - "access-control-allow-origin": origin, - vary: "Origin", - }; -} - -function baseCorsHeaders(): Record { - return { - "access-control-allow-headers": "content-type", - "access-control-allow-methods": "GET, POST, OPTIONS", - }; -} - -function isAllowedLoopbackOrigin(origin: string): boolean { - try { - const url = new URL(origin); - if (url.protocol !== "http:" && url.protocol !== "https:") return false; - return ( - url.hostname === "localhost" || - url.hostname === "127.0.0.1" || - url.hostname === "[::1]" - ); - } catch { - return false; - } -} diff --git a/packages/cli/src/lib/functions/init.ts b/packages/cli/src/lib/functions/init.ts index 2f56076425..870fc3ea9a 100644 --- a/packages/cli/src/lib/functions/init.ts +++ b/packages/cli/src/lib/functions/init.ts @@ -1,57 +1,6 @@ -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { createFunctionProject } from "@browserbasehq/sdk-functions/core"; -import { fail } from "../errors.js"; - -const envTemplate = `# Browserbase Configuration -# Get your API key from https://browserbase.com/settings - -BROWSERBASE_API_KEY=your_api_key_here -`; - -const gitignoreTemplate = `node_modules/ -.env -.env.local -dist/ -.browserbase/ -*.log -.DS_Store -`; - -const starterFunctionTemplate = `import { defineFn } from "@browserbasehq/sdk-functions"; -import { chromium } from "playwright-core"; - -defineFn("my-function", async (context) => { - const browser = await chromium.connectOverCDP(context.session.connectUrl); - const page = browser.contexts()[0]!.pages()[0]!; - - await page.goto("https://news.ycombinator.com"); - await page.waitForSelector(".athing", { timeout: 30_000 }); - - const titles = await page.$$eval(".athing .titleline > a", (elements) => - elements.slice(0, 3).map((element) => element.textContent), - ); - - return { - message: "Fetched top Hacker News titles", - titles, - }; -}); -`; - -const tsconfigTemplate = `{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true - } -} -`; +import { runFunctionsCore } from "./shared.js"; export interface InitFunctionsProjectOptions { packageManager: "npm" | "pnpm"; @@ -62,68 +11,27 @@ export async function initFunctionsProject({ packageManager, projectName, }: InitFunctionsProjectOptions): Promise { - if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(projectName)) { - fail( - `Invalid project name "${projectName}". Use a leading letter, then letters, numbers, hyphens, or underscores.`, - ); - } - - ensureCommand(packageManager); - - const projectRoot = resolve(projectName); - if (existsSync(projectRoot)) { - fail(`Directory already exists: ${projectRoot}`); - } - - await mkdir(projectRoot, { recursive: true }); - - const packageJson = { - name: projectName, - private: true, - type: "module", - scripts: { - dev: "browse functions dev index.ts", - deploy: "browse functions publish index.ts", - }, - }; - - await writeFile( - join(projectRoot, "package.json"), - `${JSON.stringify(packageJson, null, 2)}\n`, - ); - await writeFile(join(projectRoot, ".env"), envTemplate); - await writeFile(join(projectRoot, ".gitignore"), gitignoreTemplate); - await writeFile(join(projectRoot, "index.ts"), starterFunctionTemplate); - await writeFile(join(projectRoot, "tsconfig.json"), tsconfigTemplate); - - const install = packageManager === "pnpm" ? ["add"] : ["install"]; - const installDev = - packageManager === "pnpm" ? ["add", "-D"] : ["install", "--save-dev"]; - - runPackageManager( - packageManager, - [...install, "@browserbasehq/sdk-functions", "playwright-core"], - projectRoot, - ); - runPackageManager( - packageManager, - [...installDev, "typescript", "@types/node"], - projectRoot, + const result = await runFunctionsCore(() => + createFunctionProject({ + packageManager, + projectName, + scripts: { + deploy: "browse functions publish index.ts", + dev: "browse functions dev index.ts", + }, + onOutput(_stream, text) { + // Keep stdout parseable for the command's final JSON result. + process.stderr.write(text); + }, + }), ); - if (!existsSync(join(projectRoot, ".git"))) { - spawnSync("git", ["init"], { - cwd: projectRoot, - stdio: "ignore", - }); - } - console.log( JSON.stringify( { ok: true, - packageManager, - projectRoot, + packageManager: result.packageManager, + projectRoot: result.projectRoot, nextSteps: [ `cd ${projectName}`, "Edit .env with your Browserbase API key", @@ -136,32 +44,3 @@ export async function initFunctionsProject({ ), ); } - -function ensureCommand(command: string): void { - const result = spawnSync(command, ["--version"], { stdio: "ignore" }); - if (result.error || result.status !== 0) { - fail(`${command} is required but was not found on PATH.`); - } -} - -function runPackageManager( - packageManager: "npm" | "pnpm", - args: string[], - cwd: string, -): void { - const result = spawnSync(packageManager, args, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - }); - - if (result.stdout.length > 0) { - process.stderr.write(result.stdout); - } - if (result.stderr.length > 0) { - process.stderr.write(result.stderr); - } - - if (result.error || result.status !== 0) { - fail(`Failed to install dependencies with ${packageManager}.`); - } -} diff --git a/packages/cli/src/lib/functions/invoke.ts b/packages/cli/src/lib/functions/invoke.ts index 8e10ea7ace..4d28a2cab2 100644 --- a/packages/cli/src/lib/functions/invoke.ts +++ b/packages/cli/src/lib/functions/invoke.ts @@ -1,11 +1,14 @@ -import { fail } from "../errors.js"; +import { + FunctionsCoreError, + invokeFunction as invokeFunctionCore, + parseJsonArgument, + type InvocationResponse, +} from "@browserbasehq/sdk-functions/core"; + import { setRunTelemetryCompletion } from "../run-telemetry.js"; import { - functionsGet, - functionsPost, - parseOptionalJsonValueArg, - pollUntil, - resolveFunctionsApiConfig, + rethrowFunctionsCoreError, + resolveFunctionsCoreOptions, } from "./shared.js"; export interface InvokeFunctionOptions { @@ -17,65 +20,31 @@ export interface InvokeFunctionOptions { params?: string; } -interface InvocationResponse { - id: string; - functionId: string; - status: string; - sessionId?: string; - startedAt?: string; - endedAt?: string; - results?: unknown; -} - export async function invokeFunction( options: InvokeFunctionOptions, ): Promise { - const config = resolveFunctionsApiConfig(options); - - if (options.checkStatus) { - const status = await functionsGet( - config, - `/v1/functions/invocations/${options.checkStatus}`, - ); - console.log(JSON.stringify(status, null, 2)); - return; - } - - if (!options.functionId) { - fail("functionId is required unless --check-status is used."); - } - - const params = parseOptionalJsonValueArg(options.params, "params"); - const invocation = await functionsPost( - config, - `/v1/functions/${options.functionId}/invoke`, - { params }, - ); - - if (options.noWait) { - console.log(JSON.stringify(invocation, null, 2)); - return; - } - - const finalStatus = await pollUntil( - () => - functionsGet( - config, - `/v1/functions/invocations/${invocation.id}`, - ), - { - done: (result) => !["PENDING", "RUNNING"].includes(result.status), - intervalMs: 1_000, - maxAttempts: 900, - }, - ); - - console.log(JSON.stringify(finalStatus, null, 2)); - - if (finalStatus.status === "FAILED") { - setRunTelemetryCompletion({ - resultCode: "functions_invocation_failed", + try { + const coreOptions = resolveFunctionsCoreOptions(options); + const result = await invokeFunctionCore({ + ...coreOptions, + ...(options.checkStatus ? { checkStatus: options.checkStatus } : {}), + ...(options.functionId ? { functionId: options.functionId } : {}), + noWait: options.noWait, + params: parseJsonArgument(options.params, "params"), }); - process.exitCode = 1; + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + if ( + error instanceof FunctionsCoreError && + error.code === "invocation_failed" + ) { + console.log( + JSON.stringify(error.responseBody as InvocationResponse, null, 2), + ); + setRunTelemetryCompletion({ resultCode: "functions_invocation_failed" }); + process.exitCode = 1; + return; + } + rethrowFunctionsCoreError(error); } } diff --git a/packages/cli/src/lib/functions/publish.ts b/packages/cli/src/lib/functions/publish.ts index a71416f22b..80ac00a770 100644 --- a/packages/cli/src/lib/functions/publish.ts +++ b/packages/cli/src/lib/functions/publish.ts @@ -1,27 +1,13 @@ -import archiver from "archiver"; -import ignore from "ignore"; import { - copyFileSync, - createWriteStream, - existsSync, - mkdirSync, - readFileSync, - rmSync, -} from "node:fs"; -import { readFile, readdir, stat } from "node:fs/promises"; -import { spawnSync } from "node:child_process"; -import { tmpdir } from "node:os"; -import { dirname, join, relative } from "node:path"; -import { randomUUID } from "node:crypto"; + FunctionsCoreError, + publishFunction as publishFunctionCore, + type BuildStatusResponse, +} from "@browserbasehq/sdk-functions/core"; -import { fail } from "../errors.js"; import { setRunTelemetryCompletion } from "../run-telemetry.js"; import { - functionsGet, - functionsRequest, - pollUntil, - resolveEntrypoint, - resolveFunctionsApiConfig, + rethrowFunctionsCoreError, + resolveFunctionsCoreOptions, } from "./shared.js"; export interface PublishFunctionOptions { @@ -29,251 +15,44 @@ export interface PublishFunctionOptions { baseUrl?: string; dryRun: boolean; entrypoint: string; + projectId?: string; } -interface BuildUploadResponse { - id?: string; -} - -interface BuildStatusResponse { - id: string; - status: string; - request?: { - entrypoint?: string; - }; - builtFunctions?: Array<{ - id: string; - name: string; - createdVersion?: { - id: string; - }; - }>; -} - -const defaultIgnorePatterns = [ - "node_modules/", - ".git/", - ".env", - ".env.*", - "*.log", - ".DS_Store", - "dist/", - "build/", - "*.zip", - "*.tar", - "*.tar.gz", - ".vscode/", - ".idea/", - ".browserbase/", -]; - export async function publishFunction( options: PublishFunctionOptions, ): Promise { - const entrypoint = await resolveEntrypoint(options.entrypoint); - const config = resolveFunctionsApiConfig(options); - const entrypointPath = relative(process.cwd(), entrypoint); - - if (options.dryRun) { - const entries = await listPublishEntries(process.cwd()); - console.log( - JSON.stringify( - { - archivePath: null, - baseUrl: config.baseUrl, - dryRun: true, - entrypoint: entrypointPath, - files: entries, - }, - null, - 2, - ), - ); - return; - } - - const { archivePath } = await createArchive(process.cwd()); try { - const formData = new FormData(); - formData.append("metadata", JSON.stringify({ entrypoint: entrypointPath })); - formData.append( - "archive", - new Blob([await readFile(archivePath)], { type: "application/gzip" }), - "archive.tar.gz", - ); - - const uploadResponse = await functionsRequest( - config, - "/v1/functions/builds", - { - method: "POST", - body: formData, - }, - ); - - const uploaded = (await uploadResponse.json()) as BuildUploadResponse; - if (!uploaded.id) { - fail("Build upload completed without returning a build ID.", 1, { - resultCode: "functions_build_missing_id", - }); - } - - const build = await pollUntil( - () => - functionsGet( - config, - `/v1/functions/builds/${uploaded.id}`, - ), - { - done: (result) => !["PENDING", "RUNNING"].includes(result.status), - intervalMs: 2_000, - maxAttempts: 100, - }, - ); - - console.log(JSON.stringify(build, null, 2)); - - if (build.status === "FAILED") { - setRunTelemetryCompletion({ - resultCode: "functions_build_failed", - }); - process.exitCode = 1; - } - } finally { - rmSync(archivePath, { force: true }); - } -} - -async function createArchive(root: string): Promise<{ - archivePath: string; - entries: string[]; -}> { - const archivePath = join( - tmpdir(), - `browserbase-functions-${randomUUID()}.tar.gz`, - ); - const sourceEntries = await listPublishEntries(root); - const { entries, generatedLockfilePath } = ensureArchiveLockfile( - root, - sourceEntries, - ); - - try { - await new Promise((resolvePromise, reject) => { - const output = createWriteStream(archivePath); - const archive = archiver("tar", { - gzip: true, - gzipOptions: { level: 9 }, - }); - - archive.on("error", reject); - archive.on("warning", (warning: Error & { code?: string }) => { - if (warning.code === "ENOENT") { - return; - } - reject(warning); - }); - output.on("close", () => resolvePromise()); - output.on("error", reject); - - archive.pipe(output); - - for (const entry of entries) { - if (entry === "package-lock.json" && generatedLockfilePath) { - archive.file(generatedLockfilePath, { name: entry }); - } else { - archive.file(join(root, entry), { name: entry }); - } - } - - archive.finalize().catch(reject); + const coreOptions = resolveFunctionsCoreOptions(options); + const result = await publishFunctionCore({ + ...coreOptions, + dryRun: options.dryRun, + entrypoint: options.entrypoint, + ...(options.projectId ? { projectId: options.projectId } : {}), }); - } finally { - if (generatedLockfilePath) { - rmSync(dirname(generatedLockfilePath), { recursive: true, force: true }); - } - } - - return { archivePath, entries }; -} - -async function listPublishEntries(root: string): Promise { - const ignoreMatcher = await loadIgnoreMatcher(root); - return await listArchiveEntries(root, root, ignoreMatcher); -} - -function ensureArchiveLockfile( - root: string, - entries: string[], -): { entries: string[]; generatedLockfilePath?: string } { - if ( - !entries.includes("package.json") || - entries.includes("package-lock.json") - ) { - return { entries }; - } - - const tempDir = join(tmpdir(), `bb-functions-lockgen-${randomUUID()}`); - mkdirSync(tempDir, { recursive: true }); - copyFileSync(join(root, "package.json"), join(tempDir, "package.json")); - - const result = spawnSync("npm", ["install", "--package-lock-only"], { - cwd: tempDir, - stdio: "pipe", - }); - - if (result.status !== 0) { - rmSync(tempDir, { recursive: true, force: true }); - fail( - "Failed to generate package-lock.json for the Functions build archive.", - ); - } - - return { - entries: [...entries, "package-lock.json"].sort(), - generatedLockfilePath: join(tempDir, "package-lock.json"), - }; -} -async function loadIgnoreMatcher(root: string) { - const matcher = ignore(); - matcher.add(defaultIgnorePatterns); - - const gitignorePath = join(root, ".gitignore"); - if (existsSync(gitignorePath)) { - matcher.add(readFileSync(gitignorePath, "utf8")); - } - - return matcher; -} - -async function listArchiveEntries( - root: string, - current: string, - matcher: ignore.Ignore, -): Promise { - const entries = await readdir(current, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const absolutePath = join(current, entry.name); - const relativePath = relative(root, absolutePath) || "."; - const ignorePath = entry.isDirectory() ? `${relativePath}/` : relativePath; - - if (relativePath !== "." && matcher.ignores(ignorePath)) { - continue; - } - - if (entry.isDirectory()) { - files.push(...(await listArchiveEntries(root, absolutePath, matcher))); - continue; + if (result.dryRun) { + console.log( + JSON.stringify( + { + archivePath: null, + ...result, + }, + null, + 2, + ), + ); + return; } - - const fileStats = await stat(absolutePath); - if (fileStats.isFile()) { - files.push(relativePath); + console.log(JSON.stringify(result.build, null, 2)); + } catch (error) { + if (error instanceof FunctionsCoreError && error.code === "build_failed") { + console.log( + JSON.stringify(error.responseBody as BuildStatusResponse, null, 2), + ); + setRunTelemetryCompletion({ resultCode: "functions_build_failed" }); + process.exitCode = 1; + return; } + rethrowFunctionsCoreError(error); } - - return files.sort(); } diff --git a/packages/cli/src/lib/functions/shared.ts b/packages/cli/src/lib/functions/shared.ts index 1be7e9bcd5..fccdb2dc82 100644 --- a/packages/cli/src/lib/functions/shared.ts +++ b/packages/cli/src/lib/functions/shared.ts @@ -1,166 +1,79 @@ -import { stat } from "node:fs/promises"; -import { extname, resolve } from "node:path"; - -import { CommandFailure, fail } from "../errors.js"; import { - classifyCommandHttpFailure, - readBrowserbaseError, - resolveApiKey, -} from "../cloud/api.js"; -import { setRunTelemetryCompletion } from "../run-telemetry.js"; - -const defaultFunctionsBaseUrl = "https://api.browserbase.com"; + FunctionsCoreError, + type ResolveFunctionsApiConfigOptions, +} from "@browserbasehq/sdk-functions/core"; -export interface FunctionsApiConfig { - apiKey: string; - baseUrl: string; -} - -export interface PollOptions { - done: (value: T) => boolean; - intervalMs?: number; - maxAttempts?: number; -} +import { classifyCommandHttpFailure, resolveApiKey } from "../cloud/api.js"; +import { fail } from "../errors.js"; +import { setRunTelemetryCompletion } from "../run-telemetry.js"; -export function resolveFunctionsApiConfig(args: { +export interface FunctionsApiOverrides { apiKey?: string; baseUrl?: string; -}): FunctionsApiConfig { - return { - apiKey: resolveApiKey(args), - baseUrl: - args.baseUrl || - process.env.BROWSERBASE_BASE_URL || - process.env.BROWSERBASE_API_BASE_URL || - defaultFunctionsBaseUrl, - }; -} - -export async function functionsRequest( - config: FunctionsApiConfig, - path: string, - init: RequestInit = {}, -): Promise { - let response: Response; - try { - response = await fetch(new URL(path, config.baseUrl), { - ...init, - headers: { - "x-bb-api-key": config.apiKey, - ...(init.headers ?? {}), - }, - }); - } catch (error) { - if (error instanceof CommandFailure) { - throw error; - } - fail(error instanceof Error ? error.message : String(error), 1, { - resultCode: "request_no_response", - requestHadHttpResponse: false, - }); - } - - setRunTelemetryCompletion({ - httpStatus: response.status, - requestHadHttpResponse: true, - }); - - if (!response.ok) { - fail(await readBrowserbaseError(response), 1, { - resultCode: classifyCommandHttpFailure("functions", response.status), - httpStatus: response.status, - requestHadHttpResponse: true, - }); - } - - return response; } -export async function functionsGet( - config: FunctionsApiConfig, - path: string, -): Promise { - const response = await functionsRequest(config, path); - return (await response.json()) as T; -} - -export async function functionsPost( - config: FunctionsApiConfig, - path: string, - body: unknown, -): Promise { - const response = await functionsRequest(config, path, { - method: "POST", - headers: { - "content-type": "application/json", +export function resolveFunctionsCoreOptions( + args: FunctionsApiOverrides, +): ResolveFunctionsApiConfigOptions { + const options: ResolveFunctionsApiConfigOptions = { + apiKey: resolveApiKey(args), + onResponse(response) { + setRunTelemetryCompletion({ + httpStatus: response.status, + requestHadHttpResponse: true, + }); }, - body: JSON.stringify(body), - }); - return (await response.json()) as T; -} - -export async function pollUntil( - loader: () => Promise, - options: PollOptions, -): Promise { - const intervalMs = options.intervalMs ?? 1_000; - const maxAttempts = options.maxAttempts ?? 120; - - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const result = await loader(); - if (options.done(result)) { - return result; - } - await new Promise((resolvePromise) => - setTimeout(resolvePromise, intervalMs), - ); + }; + if (args.baseUrl) { + options.baseUrl = args.baseUrl; } - - fail( - "Timed out while waiting for the Browserbase Functions operation to complete.", - 1, - { resultCode: "functions_timeout" }, - ); + return options; } -export async function resolveEntrypoint(entrypoint: string): Promise { - const absolutePath = resolve(entrypoint); - let stats; +export async function runFunctionsCore( + operation: () => Promise, +): Promise { try { - stats = await stat(absolutePath); - } catch { - fail(`Entrypoint file not found: ${absolutePath}`); - } - - if (!stats.isFile()) { - fail(`Entrypoint must be a file: ${absolutePath}`); - } - - const extension = extname(absolutePath).toLowerCase(); - if ( - ![".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts"].includes(extension) - ) { - fail(`Unsupported entrypoint extension: ${extension}`); + return await operation(); + } catch (error) { + rethrowFunctionsCoreError(error); } - - return absolutePath; } -export function parseOptionalJsonValueArg( - rawValue: unknown, - label: string, -): unknown { - if (!rawValue) { - return {}; +export function rethrowFunctionsCoreError(error: unknown): never { + if (!(error instanceof FunctionsCoreError)) { + fail(error instanceof Error ? error.message : String(error)); } - if (typeof rawValue !== "string") { - fail(`${label} must be provided as a JSON string.`); + const metadata: { + httpStatus?: number; + requestHadHttpResponse?: boolean; + resultCode: string; + } = { + resultCode: resultCodeForCoreError(error), + }; + if (error.httpStatus !== undefined) { + metadata.httpStatus = error.httpStatus; + metadata.requestHadHttpResponse = true; + } else if (error.code === "request_failed") { + metadata.requestHadHttpResponse = false; } + fail(error.message, 1, metadata); +} - try { - return JSON.parse(rawValue); - } catch (error) { - fail(`Invalid JSON for ${label}: ${(error as Error).message}`); +function resultCodeForCoreError(error: FunctionsCoreError): string { + if (error.code === "http_error" && error.httpStatus !== undefined) { + return ( + classifyCommandHttpFailure("functions", error.httpStatus) ?? + "functions_http_error" + ); } + const codes: Partial> = { + build_failed: "functions_build_failed", + build_missing_id: "functions_build_missing_id", + invocation_failed: "functions_invocation_failed", + request_failed: "request_no_response", + timeout: "functions_timeout", + }; + return codes[error.code] ?? `functions_${error.code}`; } diff --git a/packages/cli/tests/cli-functions-contract.test.ts b/packages/cli/tests/cli-functions-contract.test.ts index 57ab7e69d9..6995630048 100644 --- a/packages/cli/tests/cli-functions-contract.test.ts +++ b/packages/cli/tests/cli-functions-contract.test.ts @@ -50,6 +50,15 @@ afterEach(async () => { }); describe("functions API contracts", () => { + it("imports the SDK core without executing the bundled bb CLI", async () => { + const core = await import("@browserbasehq/sdk-functions/core"); + + expect(core.createFunctionProject).toBeTypeOf("function"); + expect(core.startDevServer).toBeTypeOf("function"); + expect(core.publishFunction).toBeTypeOf("function"); + expect(core.invokeFunction).toBeTypeOf("function"); + }); + itPosix("publishes a Functions archive and polls build status", async () => { const cwd = await createFunctionFixture("functions-publish-"); @@ -85,7 +94,9 @@ describe("functions API contracts", () => { "index.ts", "--api-key", "test-key", - "--base-url", + "--project-id", + "test-project", + "--api-url", baseUrl, ], { cwd }, @@ -101,6 +112,7 @@ describe("functions API contracts", () => { "multipart/form-data", ); expect(requests[0]?.bodyText).toContain('"entrypoint":"index.ts"'); + expect(requests[0]?.bodyText).toContain('"projectId":"test-project"'); expectRequest( requests[1], "GET", @@ -133,6 +145,8 @@ describe("functions API contracts", () => { "--dry-run", "--api-key", "test-key", + "--project-id", + "test-project", ], { cwd, @@ -147,9 +161,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( @@ -183,6 +199,8 @@ describe("functions API contracts", () => { "index.ts", "--api-key", "test-key", + "--project-id", + "test-project", "--base-url", baseUrl, ], @@ -198,6 +216,29 @@ 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) => { @@ -237,7 +278,7 @@ describe("functions API contracts", () => { '{"url":"https://example.com"}', "--api-key", "test-key", - "--base-url", + "--api-url", baseUrl, ]); @@ -338,9 +379,17 @@ describe("functions scaffolding and local dev", () => { 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 () => { @@ -383,6 +432,8 @@ describe("functions scaffolding and local dev", () => { String(port), "--api-key", "test-key", + "--project-id", + "test-project", "--base-url", baseUrl, ], @@ -434,12 +485,23 @@ 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); @@ -480,6 +542,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -557,6 +620,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", }, @@ -605,6 +669,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -680,6 +745,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -758,6 +824,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -772,9 +839,10 @@ 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( + message: expect.stringContaining( "Invalid runtime response payload", ), + type: "RuntimeResponseError", }, }); await waitForFileText(runtimeStatusLog, "400\n"); @@ -783,9 +851,10 @@ describe("functions scaffolding and local dev", () => { expect(second.status).toBe(500); await expect(second.json()).resolves.toMatchObject({ error: { - errorMessage: expect.stringContaining( + message: expect.stringContaining( "Invalid runtime response payload", ), + type: "RuntimeResponseError", }, }); await waitForFileText(runtimeStatusLog, "400\n400\n"); @@ -821,6 +890,7 @@ describe("functions scaffolding and local dev", () => { cwd, env: { ...process.env, + BROWSERBASE_PROJECT_ID: "test-project", NODE_ENV: "test", }, stdio: ["ignore", "pipe", "pipe"], @@ -828,7 +898,9 @@ describe("functions scaffolding and local dev", () => { ); cleanupProcesses.push(child); - await waitForStdout(child, '"ok": true', 15_000); + // A runtime that connected and then exited must not remain reported as + // healthy while the dev server waits for a replacement process. + await waitForStdout(child, '"runtimeConnected": false', 15_000); child.kill("SIGTERM"); await waitForExit(child, 5_000); expect(child.exitCode ?? child.signalCode).not.toBe(null); @@ -863,7 +935,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); @@ -916,6 +988,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, }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a47fb9db1d..a56839d9b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,6 +241,9 @@ catalogs: '@browserbasehq/sdk': specifier: ^2.16.0 version: 2.16.0 + '@browserbasehq/sdk-functions': + specifier: github:browserbase/sdk-functions-node#097ad455f4af8ca2f4932da7641373d77963d2d1 + version: 1.0.2 '@changesets/changelog-github': specifier: 0.7.0 version: 0.7.0 @@ -438,6 +441,9 @@ importers: '@browserbasehq/sdk': specifier: ^2.17.0 version: 2.20.0 + '@browserbasehq/sdk-functions': + specifier: 'catalog:' + version: https://codeload.github.com/browserbase/sdk-functions-node/tar.gz/097ad455f4af8ca2f4932da7641373d77963d2d1 '@browserbasehq/stagehand': specifier: workspace:* version: link:../sdk-ts @@ -447,9 +453,6 @@ importers: '@vercel/detect-agent': specifier: ^1.2.3 version: 1.2.3 - archiver: - specifier: ^7.0.1 - version: 7.0.1 deepmerge: specifier: ^4.3.1 version: 4.3.1 @@ -462,18 +465,12 @@ importers: http-status-codes: specifier: ^2.3.0 version: 2.3.0 - ignore: - specifier: ^7.0.5 - version: 7.0.5 node-html-markdown: specifier: ^1.3.0 version: 1.3.0 semver: specifier: ^7.7.4 version: 7.8.5 - tsx: - specifier: ^4.20.6 - version: 4.23.1 ws: specifier: ^8.18.3 version: 8.21.0(bufferutil@4.1.0) @@ -484,9 +481,6 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@8.1.1)) - '@types/archiver': - specifier: ^6.0.3 - version: 6.0.4 '@types/node': specifier: ^20.11.30 version: 20.19.43 @@ -1823,6 +1817,11 @@ packages: '@opentelemetry/sdk-trace-base': '>=1.9.0' braintrust: '>=1.0.0-0' + '@browserbasehq/sdk-functions@https://codeload.github.com/browserbase/sdk-functions-node/tar.gz/097ad455f4af8ca2f4932da7641373d77963d2d1': + resolution: {gitHosted: true, tarball: https://codeload.github.com/browserbase/sdk-functions-node/tar.gz/097ad455f4af8ca2f4932da7641373d77963d2d1} + version: 1.0.2 + hasBin: true + '@browserbasehq/sdk@2.16.0': resolution: {integrity: sha512-mPAuLRU9jWR7o0KJi9+gQnOBDUSIkoKbbFv4HjrA+80qWVcFacrNPlZmf4mguQnfZ0oP2t5c3ws6yuFyAX9vpA==} @@ -3769,9 +3768,6 @@ packages: '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} - '@types/archiver@6.0.4': - resolution: {integrity: sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3862,9 +3858,6 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - '@types/readdir-glob@1.1.5': - resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} - '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -4607,6 +4600,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -9934,6 +9931,22 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) braintrust: 3.28.0(@aws-sdk/credential-provider-web-identity@3.972.74)(zod@4.4.3) + '@browserbasehq/sdk-functions@https://codeload.github.com/browserbase/sdk-functions-node/tar.gz/097ad455f4af8ca2f4932da7641373d77963d2d1': + dependencies: + '@browserbasehq/sdk': 2.20.0 + archiver: 7.0.1 + chalk: 5.6.2 + commander: 14.0.3 + dotenv: 17.4.2 + ignore: 7.0.5 + tsx: 4.23.1 + zod: 4.4.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - encoding + - react-native-b4a + '@browserbasehq/sdk@2.16.0': dependencies: '@types/node': 18.19.130 @@ -12307,10 +12320,6 @@ snapshots: dependencies: '@types/estree': 1.0.9 - '@types/archiver@6.0.4': - dependencies: - '@types/readdir-glob': 1.1.5 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -12410,10 +12419,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/readdir-glob@1.1.5': - dependencies: - '@types/node': 25.9.4 - '@types/retry@0.12.0': {} '@types/semver@7.8.0': {} @@ -13307,6 +13312,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e4e95b596e..0f2c160abc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,6 +52,7 @@ catalog: tsx: ^4.23.1 openai: ^6.48.0 "@browserbasehq/sdk": ^2.16.0 + "@browserbasehq/sdk-functions": github:browserbase/sdk-functions-node#097ad455f4af8ca2f4932da7641373d77963d2d1 fflate: ^0.8.3 mint: 4.2.788 publint: ^0.3.8 @@ -59,6 +60,9 @@ catalog: overrides: vite: "catalog:" allowBuilds: + # TODO(functions-core-release): remove this git-dependency build allowance once + # @browserbasehq/sdk-functions/core is available from npm. + "@browserbasehq/sdk-functions@https://codeload.github.com/browserbase/sdk-functions-node/tar.gz/097ad455f4af8ca2f4932da7641373d77963d2d1": true "@ast-grep/lang-go": true "@ast-grep/lang-python": true "@google/genai": false