diff --git a/README.md b/README.md index d268398..4137e3d 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,37 @@ defineFn( The `bb` CLI is included with the package. +## Programmatic core + +CLI hosts can reuse the same scaffold, local-development, publishing, and +invocation implementation without invoking the `bb` executable: + +```ts +import { + invokeFunction, + publishFunction, +} from "@browserbasehq/sdk-functions/core"; + +const published = await publishFunction({ + apiKey: process.env.BROWSERBASE_API_KEY, + entrypoint: "index.ts", +}); + +if (!published.dryRun) { + const functionId = published.build.builtFunctions?.[0]?.id; + if (functionId) { + const invocation = await invokeFunction({ functionId }); + console.log(invocation.results); + } +} +``` + +The core API returns typed values and throws `FunctionsCoreError`; it does not +parse CLI arguments, format terminal output, install signal handlers, call +`process.exit()`, or emit telemetry. Those concerns remain with the importing +CLI. `startDevServer()` returns a handle with an idempotent `close()` method so +the host owns its process lifecycle. + | Command | Description | | ------------------------- | -------------------------------------------------------------- | | `bb init [project-name]` | Scaffold a new project (defaults to `my-browserbase-function`) | diff --git a/package.json b/package.json index ff4c0de..139e065 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,19 @@ "type": "module", "exports": { ".": { - "types": "./dist/index.d.ts", + "types": "./dist/types/index.d.ts", "require": "./dist/index.cjs", "import": "./dist/index.js" + }, + "./core": { + "types": "./dist/types/core/index.d.ts", + "require": "./dist/core.cjs", + "import": "./dist/core.js" } }, "main": "./dist/index.cjs", "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "types": "./dist/types/index.d.ts", "bin": { "bb": "dist/cli.js" }, @@ -23,21 +28,22 @@ "./dist/index.js" ], "scripts": { - "build": "rm -rf dist && tsup", + "build": "rm -rf dist && tsup && tsc --project tsconfig.build.json", "build:tests": "tsc --project tsconfig.test.json", "eslint": "eslint ./src", "eslint:fix": "eslint --fix ./src", "lint": "$npm_execpath run eslint && $npm_execpath run prettier && $npm_execpath run typecheck", "prettier": "prettier . --check --cache", "prettier:fix": "prettier . --write --cache", + "prepare": "pnpm build", "test": "$npm_execpath build:tests && node --test dist-test/**/*.test.js", "test:only": "$npm_execpath build:tests && node --test-only --test dist-test/**/*.test.js", "build:integration": "tsc --project tsconfig.integration.json", "pretest:integration": "$npm_execpath run build && pnpm pack", - "test:integration": "$npm_execpath run build:integration && node --test --test-timeout 120000 dist-integration-test/tests/integration/**/*.test.js", + "test:integration": "$npm_execpath run build:integration && node --test --test-timeout 120000 dist-integration-test/tests/integration/build-flow.test.js dist-integration-test/tests/integration/manifest-generation.test.js dist-integration-test/tests/integration/cli/*.test.js", "posttest:integration": "rm -f browserbasehq-sdk-functions-*.tgz", "pretest:e2e": "$npm_execpath run build && pnpm pack", - "test:e2e": "$npm_execpath run build:integration && node --test --test-timeout 300000 dist-integration-test/tests/e2e/**/*.test.js", + "test:e2e": "$npm_execpath run build:integration && node --test --test-timeout 300000 dist-integration-test/tests/e2e/e2e.test.js", "posttest:e2e": "rm -f browserbasehq-sdk-functions-*.tgz", "typecheck": "tsc --noEmit" }, @@ -65,6 +71,7 @@ "chalk": "^5.6.2", "commander": "^14.0.2", "dotenv": "^17.2.3", + "ignore": "^7.0.5", "tsx": "^4.20.5", "zod": "^4.1.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c998087..514dff9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: dotenv: specifier: ^17.2.3 version: 17.2.3 + ignore: + specifier: ^7.0.5 + version: 7.0.5 tsx: specifier: ^4.20.5 version: 4.20.5 diff --git a/src/cli/dev/bridge.ts b/src/cli/dev/bridge.ts deleted file mode 100644 index ffb3a32..0000000 --- a/src/cli/dev/bridge.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { ServerResponse } from "http"; -import { randomUUID } from "crypto"; -import chalk from "chalk"; -import type { FunctionInvocationContext } from "../../schemas/invocation.js"; - -interface HeldConnection { - response: ServerResponse; - timestamp: number; -} - -export interface InvocationPayload { - functionName: string; - params: unknown; - context: FunctionInvocationContext; -} - -export interface RuntimeError { - errorMessage: string; - errorType: string; - stackTrace: string[]; -} - -/** - * Interface for managing the lifecycle of invocations, bridging between external invoke requests - * and the function runtime's polling mechanism. - */ -export interface IInvocationBridge { - /** - * Set a callback to be called when a session should be cleaned up. - */ - setSessionCleanupCallback( - callback: (sessionId: string) => Promise, - ): void; - - /** - * Hold a connection from the runtime waiting for the next invocation. - * This corresponds to the SDK calling GET /invocation/next. - */ - holdNextConnection(response: ServerResponse): void; - - /** - * Trigger an invocation by completing the held /next connection with invoke data - * and holding the invoke connection until the function completes. - */ - triggerInvocation( - functionName: string, - params: unknown, - context: FunctionInvocationContext, - invokeResponse: ServerResponse, - ): boolean; - - /** - * Complete the held invoke connection with a successful response. - */ - completeWithSuccess(requestId: string, result: unknown): boolean; - - /** - * Complete the held invoke connection with an error response. - */ - completeWithError(requestId: string, error: RuntimeError): boolean; - - /** - * Check if the bridge is ready to accept invocations. - */ - isReady(): boolean; - - /** - * Check if there's an active invocation. - */ - hasActiveInvocation(): boolean; - - /** - * Get the current request ID if there's an active invocation. - */ - getCurrentRequestId(): string | null; - - /** - * Check if the runtime has connected at least once. - */ - isRuntimeConnected(): boolean; -} - -/** - * Manages the lifecycle of invocations, bridging between external invoke requests - * and the function runtime's polling mechanism. - */ -export class InvocationBridge implements IInvocationBridge { - private nextConnection: HeldConnection | null = null; - private invokeConnection: HeldConnection | null = null; - private currentRequestId: string | null = null; - private currentFunctionName: string | null = null; - private currentSessionId: string | null = null; - private sessionCleanupCallback: - | ((sessionId: string) => Promise) - | null = null; - private verbose: boolean; - private runtimeConnectedOnce: boolean = false; - - constructor(verbose: boolean = false) { - this.verbose = verbose; - } - - /** - * Set a callback to be called when a session should be cleaned up. - */ - public setSessionCleanupCallback( - callback: (sessionId: string) => Promise, - ): void { - this.sessionCleanupCallback = callback; - } - - /** - * Hold a connection from the runtime waiting for the next invocation. - * This corresponds to the SDK calling GET /invocation/next. - */ - public holdNextConnection(response: ServerResponse): void { - if (this.nextConnection) { - // If there's already a held connection, close the old one - this.nextConnection.response.writeHead(503, { - "Content-Type": "application/json", - }); - this.nextConnection.response.end( - JSON.stringify({ error: "Another runtime connected" }), - ); - } - - this.nextConnection = { - response, - timestamp: Date.now(), - }; - - // Mark that runtime has connected at least once - this.runtimeConnectedOnce = true; - - if (this.verbose) { - console.log( - chalk.cyan("šŸ”Œ Function runtime connected, ready for invocations"), - ); - } - } - - /** - * Trigger an invocation by completing the held /next connection with invoke data - * and holding the invoke connection until the function completes. - */ - public triggerInvocation( - functionName: string, - params: unknown, - context: FunctionInvocationContext, - invokeResponse: ServerResponse, - ): boolean { - // Check if runtime is ready (has a held /next connection) - if (!this.nextConnection) { - if (this.verbose) { - console.log( - chalk.yellow("āš ļø No runtime connected to handle invocation"), - ); - } - return false; - } - - // Check if there's already an active invocation - if (this.invokeConnection) { - if (this.verbose) { - console.log( - chalk.yellow("āš ļø Another invocation is already in progress"), - ); - } - return false; - } - - // Generate a request ID for this invocation - const requestId = randomUUID(); - this.currentRequestId = requestId; - this.currentFunctionName = functionName; - this.currentSessionId = context.session.id; - - // Hold the invoke connection - this.invokeConnection = { - response: invokeResponse, - timestamp: Date.now(), - }; - - // Complete the held /next connection with the invocation payload - const payload: InvocationPayload = { - functionName, - params, - context, - }; - - // Set Lambda runtime headers - this.nextConnection.response.writeHead(200, { - "Content-Type": "application/json", - "Lambda-Runtime-Aws-Request-Id": requestId, - "Lambda-Runtime-Deadline-Ms": String(Date.now() + 300000), // 5 minutes from now - "Lambda-Runtime-Invoked-Function-Arn": `arn:aws:lambda:us-east-1:000000000000:function:${functionName}`, - }); - - this.nextConnection.response.end(JSON.stringify(payload)); - this.nextConnection = null; - - console.log( - chalk.blue( - `šŸš€ Invoking function '${functionName}' (request-id: ${requestId})`, - ), - ); - - return true; - } - - /** - * Complete the held invoke connection with a successful response. - */ - public completeWithSuccess(requestId: string, result: unknown): boolean { - // Validate request ID matches - if (requestId !== this.currentRequestId) { - if (this.verbose) { - console.log( - chalk.yellow( - `āš ļø Request ID mismatch: expected ${this.currentRequestId}, got ${requestId}`, - ), - ); - } - return false; - } - - // Check if there's an active invocation - if (!this.invokeConnection) { - if (this.verbose) { - console.log(chalk.yellow("āš ļø No active invocation to complete")); - } - return false; - } - - // Complete the held invoke connection - this.invokeConnection.response.writeHead(200, { - "Content-Type": "application/json", - }); - this.invokeConnection.response.end(JSON.stringify(result ?? {})); - - console.log( - chalk.green( - `āœ“ Function '${this.currentFunctionName}' completed successfully`, - ), - ); - - // Clean up session if callback is set - if (this.sessionCleanupCallback && this.currentSessionId) { - this.sessionCleanupCallback(this.currentSessionId).catch((error) => { - console.error(chalk.red("Failed to cleanup session:"), error); - }); - } - - // Clean up state - this.invokeConnection = null; - this.currentRequestId = null; - this.currentFunctionName = null; - this.currentSessionId = null; - - return true; - } - - /** - * Complete the held invoke connection with an error response. - */ - public completeWithError(requestId: string, error: RuntimeError): boolean { - // Validate request ID matches - if (requestId !== this.currentRequestId) { - if (this.verbose) { - console.log( - chalk.yellow( - `āš ļø Request ID mismatch: expected ${this.currentRequestId}, got ${requestId}`, - ), - ); - } - return false; - } - - // Check if there's an active invocation - if (!this.invokeConnection) { - if (this.verbose) { - console.log(chalk.yellow("āš ļø No active invocation to complete")); - } - return false; - } - - // Complete the held invoke connection with error - this.invokeConnection.response.writeHead(500, { - "Content-Type": "application/json", - }); - this.invokeConnection.response.end( - JSON.stringify({ - error: { - message: error.errorMessage, - type: error.errorType, - stackTrace: error.stackTrace, - }, - }), - ); - - console.log( - chalk.red( - `āœ— Function '${this.currentFunctionName}' failed: ${error.errorMessage}`, - ), - ); - - // Clean up session if callback is set - if (this.sessionCleanupCallback && this.currentSessionId) { - this.sessionCleanupCallback(this.currentSessionId).catch((error) => { - console.error(chalk.red("Failed to cleanup session:"), error); - }); - } - - // Clean up state - this.invokeConnection = null; - this.currentRequestId = null; - this.currentFunctionName = null; - this.currentSessionId = null; - - return true; - } - - /** - * Check if the bridge is ready to accept invocations. - */ - public isReady(): boolean { - return this.nextConnection !== null && this.invokeConnection === null; - } - - /** - * Check if there's an active invocation. - */ - public hasActiveInvocation(): boolean { - return this.invokeConnection !== null; - } - - /** - * Get the current request ID if there's an active invocation. - */ - public getCurrentRequestId(): string | null { - return this.currentRequestId; - } - - /** - * Check if the runtime has connected at least once. - */ - public isRuntimeConnected(): boolean { - return this.runtimeConnectedOnce && this.nextConnection !== null; - } -} diff --git a/src/cli/dev/browser-manager.ts b/src/cli/dev/browser-manager.ts deleted file mode 100644 index 619e787..0000000 --- a/src/cli/dev/browser-manager.ts +++ /dev/null @@ -1,132 +0,0 @@ -import Browserbase from "@browserbasehq/sdk"; -import chalk from "chalk"; - -export interface SessionConfig { - [key: string]: unknown; -} - -export interface Session { - id: string; - connectUrl: string; -} - -/** - * Interface for managing remote browser sessions - */ -export interface IRemoteBrowserManager { - /** - * Initialize the browser manager and check credentials - */ - initialize(): Promise; - - /** - * Create a new browser session - */ - createSession(sessionConfig?: SessionConfig): Promise; - - /** - * Close a browser session - */ - closeSession(sessionId: string): Promise; - - /** - * Check if the manager is initialized - */ - isInitialized(): boolean; -} - -/** - * Manages remote browser sessions using Browserbase - */ -export class RemoteBrowserManager implements IRemoteBrowserManager { - private browserbaseClient: Browserbase | null = null; - private apiKey: string; - private initialized: boolean = false; - - constructor() { - const foundApiKey = process.env["BROWSERBASE_API_KEY"]; - - if (!foundApiKey) { - console.error( - chalk.red("āœ— Browserbase credentials not found.\n") + - chalk.red(" Please set BROWSERBASE_API_KEY in your .env file.\n") + - chalk.gray( - " Copy .env.example to .env and fill in your credentials.", - ), - ); - throw new Error("Missing Browserbase credentials"); - } - - this.apiKey = foundApiKey; - } - - /** - * Initialize the browser manager and check credentials - */ - public async initialize(): Promise { - if (this.initialized) { - return; - } - - // Creating a new Browserbase client is sufficient to assume connection - this.browserbaseClient = new Browserbase({ - apiKey: this.apiKey, - }); - - this.initialized = true; - console.log(chalk.green("āœ“ Browserbase client initialized")); - } - - /** - * Create a new browser session - */ - public async createSession(sessionConfig?: SessionConfig): Promise { - if (!this.browserbaseClient) { - throw new Error("Browser manager not initialized"); - } - - console.log(chalk.cyan(`Creating browser session...`)); - - const createdSession = await this.browserbaseClient.sessions.create({ - ...sessionConfig, - }); - - const session: Session = { - id: createdSession.id, - connectUrl: createdSession.connectUrl, - }; - - console.log(chalk.green(`āœ“ Browser session created: ${session.id}`)); - return session; - } - - /** - * Close a browser session - */ - public async closeSession(sessionId: string): Promise { - if (!this.browserbaseClient) { - throw new Error("Browser manager not initialized"); - } - - try { - console.log(chalk.cyan(`Closing browser session: ${sessionId}...`)); - await this.browserbaseClient.sessions.update(sessionId, { - status: "REQUEST_RELEASE", - }); - console.log(chalk.green(`āœ“ Browser session closed: ${sessionId}`)); - } catch (error) { - // Session might already be closed or expired, log but don't throw - console.warn( - chalk.yellow(`āš ļø Could not close session ${sessionId}:`), - error instanceof Error ? error.message : String(error), - ); - } - } - - /** - * Check if the manager is initialized - */ - public isInitialized(): boolean { - return this.initialized; - } -} diff --git a/src/cli/dev/handlers/index.ts b/src/cli/dev/handlers/index.ts deleted file mode 100644 index 2efde7b..0000000 --- a/src/cli/dev/handlers/index.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { IncomingMessage, ServerResponse } from "http"; -import { randomUUID } from "crypto"; -import { z } from "zod"; -import chalk from "chalk"; -import { type IInvocationBridge } from "../bridge.js"; -import { type IRemoteBrowserManager } from "../browser-manager.js"; -import { type IManifestStore } from "./manifest-store.js"; -import { requestParser } from "./request-parser.js"; -import { responseBuilder } from "./response-builder.js"; -import { RuntimeError } from "../../../schemas/events.js"; -import { FunctionInvocationContext } from "../../../schemas/invocation.js"; - -/** - * Dependencies required by the request handlers - */ -export interface RequestHandlerDependencies { - bridge: IInvocationBridge; - browserManager: IRemoteBrowserManager; - manifestStore: IManifestStore; -} - -/** - * Interface for request handlers - */ -export interface IRequestHandlers { - /** - * Handle GET /2018-06-01/runtime/invocation/next - * This endpoint is called by the runtime to get the next invocation. - * We hold the connection until an invocation arrives. - */ - handleInvocationNext( - req: IncomingMessage, - res: ServerResponse, - ): Promise; - - /** - * Handle POST /v1/functions/:name/invoke - * This endpoint is called by external clients to invoke a function. - */ - handleFunctionInvoke( - req: IncomingMessage, - res: ServerResponse, - functionName: string, - ): Promise; - - /** - * Handle POST /2018-06-01/runtime/invocation/:requestId/response - * This endpoint is called by the runtime when a function completes successfully. - */ - handleInvocationResponse( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ): Promise; - - /** - * Handle POST /2018-06-01/runtime/invocation/:requestId/error - * This endpoint is called by the runtime when a function fails. - */ - handleInvocationError( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ): Promise; -} - -/** - * Implementation of request handlers for the dev server - */ -export class DevServerHandlers implements IRequestHandlers { - private readonly bridge: IInvocationBridge; - private readonly browserManager: IRemoteBrowserManager; - private readonly manifestStore: IManifestStore; - - constructor(deps: RequestHandlerDependencies) { - this.bridge = deps.bridge; - this.browserManager = deps.browserManager; - this.manifestStore = deps.manifestStore; - - // Set up the session cleanup callback in the bridge - this.bridge.setSessionCleanupCallback(async (sessionId: string) => { - await this.cleanupSession(sessionId); - }); - } - - /** - * Handle GET /2018-06-01/runtime/invocation/next - */ - public async handleInvocationNext( - _req: IncomingMessage, - res: ServerResponse, - ): Promise { - // Hold the connection in the bridge - this.bridge.holdNextConnection(res); - - // The response will be completed later when an invocation arrives - // via triggerInvocation in the bridge - } - - /** - * Handle POST /v1/functions/:name/invoke - */ - public async handleFunctionInvoke( - req: IncomingMessage, - res: ServerResponse, - functionName: string, - ): Promise { - try { - // Define the invoke request schema - const invokeSchema = z.object({ - functionName: z.string().optional(), - params: z.unknown().default({}), - context: FunctionInvocationContext.optional(), - }); - - // Parse and validate the request body - const validatedData = await requestParser.parseAndValidate( - req, - invokeSchema, - ); - - // Use function name from URL path - const finalFunctionName = functionName || validatedData.functionName; - - if (!finalFunctionName) { - responseBuilder.sendBadRequest(res, "Function name is required"); - return; - } - - // Look up function manifest to get sessionConfig - const manifest = this.manifestStore.getManifest(finalFunctionName); - - if (!manifest) { - console.error( - chalk.red(`āœ— Function "${finalFunctionName}" not found in registry`), - ); - console.error( - chalk.gray( - " Make sure the function is defined in your entrypoint file", - ), - ); - responseBuilder.sendNotFound( - res, - `Function "${finalFunctionName}" not found in registry. Make sure it is defined with defineFn() in your entrypoint file.`, - ); - return; - } - - // Always create a browser session - let session: { id: string; connectUrl: string }; - - try { - console.log( - chalk.cyan(`Creating browser session for ${finalFunctionName}...`), - ); - - // Create session with function's sessionConfig if available - const sessionConfig = manifest?.config?.sessionConfig || {}; - session = await this.browserManager.createSession(sessionConfig); - } catch (error) { - console.error(chalk.red("Failed to create browser session:"), error); - responseBuilder.sendInternalError( - res, - "Failed to create browser session", - error instanceof Error ? error.message : String(error), - ); - return; - } - - // Build context with the created session - const context = validatedData.context || { - invocation: { - id: randomUUID(), - region: "local", - }, - session: session, - }; - - // Always use the created session - context.session = session; - - // Try to trigger the invocation - const success = this.bridge.triggerInvocation( - finalFunctionName, - validatedData.params, - context, - res, - ); - - if (!success) { - // Runtime not ready or another invocation in progress - // Clean up the session we just created since we won't use it - await this.cleanupSession(session.id); - - responseBuilder.sendServiceUnavailable( - res, - this.bridge.hasActiveInvocation() - ? "Another invocation is in progress" - : "No runtime connected", - ); - return; - } - - // The response will be completed later when the function completes - // via completeWithSuccess or completeWithError in the bridge - } catch (error) { - if (error instanceof z.ZodError) { - responseBuilder.sendBadRequest(res, "Invalid request body", error); - } else { - console.error(chalk.red("Error handling invoke:"), error); - responseBuilder.sendInternalError(res); - } - } - } - - /** - * Handle POST /2018-06-01/runtime/invocation/:requestId/response - */ - public async handleInvocationResponse( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ): Promise { - try { - // Parse the response body - const body = await requestParser.parseJsonBody(req); - - // Complete the invocation with success - const success = this.bridge.completeWithSuccess(requestId, body); - - if (!success) { - responseBuilder.sendBadRequest( - res, - "No matching invocation or request ID mismatch", - ); - return; - } - - // Send acknowledgment to the runtime - responseBuilder.sendAccepted(res); - } catch (error) { - console.error(chalk.red("Error handling response:"), error); - responseBuilder.sendInternalError(res); - } - } - - /** - * Handle POST /2018-06-01/runtime/invocation/:requestId/error - */ - public async handleInvocationError( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ): Promise { - try { - // Parse and validate the error body using SDK schema - const validatedError = await requestParser.parseAndValidate( - req, - RuntimeError, - ); - - // Complete the invocation with error - const success = this.bridge.completeWithError(requestId, validatedError); - - if (!success) { - responseBuilder.sendBadRequest( - res, - "No matching invocation or request ID mismatch", - ); - return; - } - - // Send acknowledgment to the runtime - responseBuilder.sendAccepted(res); - } catch (error) { - if (error instanceof z.ZodError) { - responseBuilder.sendBadRequest(res, "Invalid error format", error); - } else { - console.error(chalk.red("Error handling error report:"), error); - responseBuilder.sendInternalError(res); - } - } - } - - /** - * Private method to cleanup a browser session - */ - private async cleanupSession(sessionId: string): Promise { - try { - await this.browserManager.closeSession(sessionId); - } catch (error) { - console.error( - chalk.red(`Failed to cleanup session ${sessionId}:`), - error, - ); - } - } -} diff --git a/src/cli/dev/handlers/manifest-store.ts b/src/cli/dev/handlers/manifest-store.ts deleted file mode 100644 index fc45351..0000000 --- a/src/cli/dev/handlers/manifest-store.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { readFileSync, existsSync, readdirSync } from "fs"; -import { join } from "path"; -import chalk from "chalk"; -import type { PersistedFunctionManifest } from "../../../types/definition.js"; -import type { JSONSchemaInput } from "../../../types/schema.js"; - -/** - * Interface for managing function manifests - */ -export interface IManifestStore { - /** - * Load manifests from the filesystem - */ - loadManifests(): void; - - /** - * Get a manifest by function name - */ - getManifest( - functionName: string, - ): PersistedFunctionManifest | undefined; - - /** - * Get the total number of loaded manifests - */ - getSize(): number; - - /** - * Check if a manifest exists for the given function name - */ - hasManifest(functionName: string): boolean; - - /** - * Get all loaded manifest names - */ - getManifestNames(): string[]; -} - -/** - * Implementation of manifest store for managing function manifests - */ -export class ManifestStore implements IManifestStore { - private manifests = new Map< - string, - PersistedFunctionManifest - >(); - private manifestsPath: string; - - constructor(manifestsPath?: string) { - this.manifestsPath = - manifestsPath || - join(process.cwd(), ".browserbase", "functions", "manifests"); - } - - /** - * Load function manifests from the filesystem - */ - public loadManifests(): void { - if (!existsSync(this.manifestsPath)) { - console.log(chalk.yellow(`āš ļø No ${this.manifestsPath} directory found`)); - console.log( - chalk.gray(" Run your entrypoint file first to generate manifests"), - ); - return; - } - - try { - const files = readdirSync(this.manifestsPath); - const jsonFiles = files.filter((f) => f.endsWith(".json")); - - for (const file of jsonFiles) { - const filePath = join(this.manifestsPath, file); - const content = readFileSync(filePath, "utf-8"); - const manifest = JSON.parse( - content, - ) as PersistedFunctionManifest; - - this.manifests.set(manifest.name, manifest); - console.log( - chalk.gray(` Loaded manifest for function: ${manifest.name}`), - ); - } - - if (this.manifests.size > 0) { - console.log( - chalk.green(`āœ“ Loaded ${this.manifests.size} function manifest(s)`), - ); - } else { - console.log( - chalk.yellow( - "āš ļø No function manifests found in .browserbase directory", - ), - ); - } - } catch (error) { - console.error(chalk.red("Failed to load function manifests:"), error); - } - } - - /** - * Get a manifest by function name - */ - public getManifest( - functionName: string, - ): PersistedFunctionManifest | undefined { - return this.manifests.get(functionName); - } - - /** - * Get the total number of loaded manifests - */ - public getSize(): number { - return this.manifests.size; - } - - /** - * Check if a manifest exists for the given function name - */ - public hasManifest(functionName: string): boolean { - return this.manifests.has(functionName); - } - - /** - * Get all loaded manifest names - */ - public getManifestNames(): string[] { - return Array.from(this.manifests.keys()); - } -} diff --git a/src/cli/dev/handlers/request-parser.ts b/src/cli/dev/handlers/request-parser.ts deleted file mode 100644 index 8c9a1d8..0000000 --- a/src/cli/dev/handlers/request-parser.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { IncomingMessage } from "http"; -import { z } from "zod"; - -/** - * Interface for request parsing operations - */ -export interface IRequestParser { - /** - * Parse JSON body from an incoming request - */ - parseJsonBody(req: IncomingMessage): Promise; - - /** - * Parse and validate JSON body with a Zod schema - */ - parseAndValidate(req: IncomingMessage, schema: z.ZodType): Promise; -} - -/** - * Request parser implementation - */ -export const requestParser: IRequestParser = { - async parseJsonBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ""; - - req.on("data", (chunk) => { - body += chunk.toString(); - }); - - req.on("end", () => { - try { - const parsed = body ? JSON.parse(body) : {}; - resolve(parsed); - } catch (error: unknown) { - reject(new Error("Invalid JSON body", { cause: error })); - } - }); - - req.on("error", reject); - }); - }, - - async parseAndValidate( - req: IncomingMessage, - schema: z.ZodType, - ): Promise { - const body = await this.parseJsonBody(req); - return schema.parse(body); - }, -}; diff --git a/src/cli/dev/handlers/response-builder.ts b/src/cli/dev/handlers/response-builder.ts deleted file mode 100644 index 6b3b3a0..0000000 --- a/src/cli/dev/handlers/response-builder.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { ServerResponse } from "http"; - -/** - * Standard response format for errors - */ -export interface ErrorResponse { - error: string; - message?: string; - details?: unknown; -} - -/** - * Standard response format for success - */ -export interface SuccessResponse { - status: string; - data?: T; -} - -/** - * Interface for response building operations - */ -export interface IResponseBuilder { - /** - * Send a JSON response with the given status code - */ - sendJson(res: ServerResponse, statusCode: number, data: unknown): void; - - /** - * Send a success response - */ - sendSuccess( - res: ServerResponse, - data?: T, - statusCode?: number, - ): void; - - /** - * Send an error response - */ - sendError( - res: ServerResponse, - error: string, - statusCode?: number, - message?: string, - details?: unknown, - ): void; - - /** - * Send a 400 Bad Request error - */ - sendBadRequest(res: ServerResponse, message: string, details?: unknown): void; - - /** - * Send a 404 Not Found error - */ - sendNotFound(res: ServerResponse, message: string): void; - - /** - * Send a 500 Internal Server Error - */ - sendInternalError( - res: ServerResponse, - message?: string, - details?: unknown, - ): void; - - /** - * Send a 503 Service Unavailable error - */ - sendServiceUnavailable(res: ServerResponse, message: string): void; - - /** - * Send a 202 Accepted response - */ - sendAccepted(res: ServerResponse, data?: unknown): void; -} - -/** - * Response builder implementation - */ -export const responseBuilder: IResponseBuilder = { - sendJson(res: ServerResponse, statusCode: number, data: unknown): void { - res.writeHead(statusCode, { "Content-Type": "application/json" }); - res.end(JSON.stringify(data)); - }, - - sendSuccess( - res: ServerResponse, - data?: T, - statusCode: number = 200, - ): void { - const response: SuccessResponse = { - status: "success", - ...(data !== undefined && { data }), - }; - this.sendJson(res, statusCode, response); - }, - - sendError( - res: ServerResponse, - error: string, - statusCode: number = 500, - message?: string, - details?: unknown, - ): void { - const response: ErrorResponse = { - error, - }; - if (message) { - response.message = message; - } - if (details) { - response.details = details; - } - this.sendJson(res, statusCode, response); - }, - - sendBadRequest( - res: ServerResponse, - message: string, - details?: unknown, - ): void { - this.sendError(res, "Bad Request", 400, message, details); - }, - - sendNotFound(res: ServerResponse, message: string): void { - this.sendError(res, "Not Found", 404, message); - }, - - sendInternalError( - res: ServerResponse, - message: string = "An internal error occurred", - details?: unknown, - ): void { - this.sendError(res, "Internal Server Error", 500, message, details); - }, - - sendServiceUnavailable(res: ServerResponse, message: string): void { - this.sendError(res, "Service Unavailable", 503, message); - }, - - sendAccepted(res: ServerResponse, data?: unknown): void { - const response = data || { status: "accepted" }; - this.sendJson(res, 202, response); - }, -}; diff --git a/src/cli/dev/index.ts b/src/cli/dev/index.ts index 5b6787f..a7bfe92 100644 --- a/src/cli/dev/index.ts +++ b/src/cli/dev/index.ts @@ -1,149 +1,47 @@ import chalk from "chalk"; -import { startServer } from "./server.js"; -import { InvocationBridge } from "./bridge.js"; -import { ProcessManager } from "./process.js"; -import { RemoteBrowserManager } from "./browser-manager.js"; -import { DevServerHandlers } from "./handlers/index.js"; -import "dotenv/config"; -import { ManifestStore } from "./handlers/manifest-store.js"; -import type { Server } from "node:http"; + +import { + startDevServer as startDevServerCore, + type DevServerHandle, +} from "../../core/index.js"; export interface DevServerOptions { entrypoint: string; port: number; host: string; - verbose: boolean; + verbose?: boolean; } -export async function startDevServer(options: DevServerOptions): Promise { - const { entrypoint, port, host, verbose } = options; - - // Check if we're in production mode - if (process.env["NODE_ENV"] === "production") { - console.warn( - chalk.yellow( - "āš ļø Warning: Running dev server in production mode. This is not recommended.", - ), - ); - } - - // Set the runtime API URL - const runtimeApiUrl = `${host}:${port}`; - - if (verbose) { - console.log(chalk.gray(`Runtime API URL: ${runtimeApiUrl}`)); - } - - // Create the invocation bridge - const bridge = new InvocationBridge(verbose); - - // Create the browser manager - const browserManager = new RemoteBrowserManager(); - await browserManager.initialize(); - - // Create and initialize the manifest store - const manifestStore = new ManifestStore(); - manifestStore.loadManifests(); - - // Create the handlers with all dependencies - const handlers = new DevServerHandlers({ - bridge, - browserManager, - manifestStore, - }); - - // Create the process manager - const processManager = new ProcessManager({ - entrypoint, - runtimeApiUrl, - verbose, +export async function startDevServerCli( + options: DevServerOptions, +): Promise { + const handle = await startDevServerCore({ + ...options, + onLog(event) { + const output = event.level === "error" ? process.stderr : process.stdout; + output.write(`${event.message}\n`); + }, }); - - // Start the server - let server: Server | null = null; - - try { - // Start the server first - server = await startServer({ - port, - host, - bridge, - browserManager, - handlers, - }); - - console.log( - chalk.green(`āœ“ Development server listening on http://${host}:${port}`), - ); - - // Then start the user's function process - console.log(chalk.cyan("Starting runtime process...")); - await processManager.start(); - - // Wait for runtime to connect with retry logic - const maxWaitTime = 10000; // 10 seconds max - const pollInterval = 200; // Check every 200ms - const startTime = Date.now(); - let runtimeConnected = false; - - while (Date.now() - startTime < maxWaitTime) { - if (bridge.isRuntimeConnected()) { - runtimeConnected = true; - console.log(chalk.green("āœ“ Runtime connected and ready")); - // Reload manifests after runtime starts as it may have created them - manifestStore.loadManifests(); - break; - } - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - } - - if (!runtimeConnected) { - console.log( - chalk.yellow( - "āš ļø Runtime is taking longer than expected to connect...", + console.log( + handle.runtimeConnected + ? chalk.green(`āœ“ Development server listening on ${handle.url}`) + : chalk.yellow( + `āš ļø Development server is listening on ${handle.url}, but the runtime has not connected yet.`, ), - ); - // Still try to reload manifests in case they were written - manifestStore.loadManifests(); - } - - // Handle graceful shutdown - const shutdown = async () => { - console.log(chalk.cyan("\nšŸ“¦ Shutting down...")); - - // Stop the user process first - await processManager.stop(); - - // Then close the server - return new Promise((resolve) => { - server?.close(() => { - console.log(chalk.green("āœ“ Server closed")); - resolve(); - }); - }); - }; - - // Handle process termination - process.on("SIGINT", async () => { - await shutdown(); - process.exit(0); - }); - - process.on("SIGTERM", async () => { - await shutdown(); - process.exit(0); - }); - } catch (error) { - console.error(chalk.red("Failed to start:"), error); - - // Clean up on error - if (processManager.isRunning()) { - await processManager.stop(); - } - if (server) { - server.close(); - } - - throw error; - } + ); + + const shutdown = async () => { + console.log(chalk.cyan("\nšŸ“¦ Shutting down...")); + await handle.close(); + }; + process.once("SIGINT", () => { + void shutdown().then(() => process.exit(0)); + }); + process.once("SIGTERM", () => { + void shutdown().then(() => process.exit(0)); + }); + return handle; } + +// Backwards-compatible internal name used by the Commander adapter. +export const startDevServer = startDevServerCli; diff --git a/src/cli/dev/process.ts b/src/cli/dev/process.ts deleted file mode 100644 index c33cd67..0000000 --- a/src/cli/dev/process.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { spawn, ChildProcess } from "node:child_process"; -import { createRequire } from "node:module"; -import chalk from "chalk"; - -export interface ProcessManagerOptions { - entrypoint: string; - runtimeApiUrl: string; - verbose: boolean; -} - -/** - * Interface for managing the lifecycle of the user's function process. - * Spawns tsx watch to enable hot reloading during development. - */ -export interface IProcessManager { - /** - * Start the user's function process using tsx watch. - */ - start(): Promise; - - /** - * Stop the user's function process. - */ - stop(): Promise; - - /** - * Check if the process is currently running. - */ - isRunning(): boolean; -} - -/** - * Manages the lifecycle of the user's function process. - * Spawns tsx watch to enable hot reloading during development. - */ -export class ProcessManager implements IProcessManager { - private process: ChildProcess | null = null; - private entrypoint: string; - private runtimeApiUrl: string; - private verbose: boolean; - private isShuttingDown = false; - - constructor(options: ProcessManagerOptions) { - this.entrypoint = options.entrypoint; - this.runtimeApiUrl = options.runtimeApiUrl; - this.verbose = options.verbose; - } - - /** - * Start the user's function process using tsx watch. - */ - public async start(): Promise { - if (this.process) { - throw new Error("Process is already running"); - } - - if (this.verbose) { - console.log(chalk.gray(`Starting runtime process...`)); - console.log( - chalk.gray( - ` Command: tsx watch --clear-screen=false ${this.entrypoint}`, - ), - ); - console.log(chalk.gray(` Working directory: ${process.cwd()}`)); - console.log(chalk.gray(` Runtime API: ${this.runtimeApiUrl}`)); - } - - const createdRequire = createRequire(import.meta.url); - const tsxCli = createdRequire.resolve("tsx/cli"); - - const args = ["watch", "--clear-screen=false", this.entrypoint]; - - // Spawn tsx watch with the user's entrypoint - this.process = spawn(process.execPath, [tsxCli, ...args], { - cwd: process.cwd(), - env: { - ...process.env, - AWS_LAMBDA_RUNTIME_API: this.runtimeApiUrl, - BB_FUNCTIONS_PHASE: "runtime", - NODE_ENV: "local", - }, - stdio: ["ignore", "pipe", "pipe"], - }); - - // Handle stdout - this.process.stdout?.on("data", (data) => { - const lines = data.toString().trim().split("\n"); - lines.forEach((line: string) => { - if (line.trim()) { - console.log(chalk.blue("[Runtime]"), line); - } - }); - }); - - // Handle stderr - this.process.stderr?.on("data", (data) => { - const lines = data.toString().trim().split("\n"); - lines.forEach((line: string) => { - if (line.trim()) { - // Check if it's a tsx watch message - if (line.includes("Watching for file changes")) { - console.log(chalk.green("āœ“ Runtime watching for file changes")); - } else if (line.includes("Restarting")) { - console.log( - chalk.yellow("↻ Runtime restarting due to file change..."), - ); - } else { - console.error(chalk.red("[Runtime Error]"), line); - } - } - }); - }); - - // Handle process exit - this.process.on("exit", (code, signal) => { - if (!this.isShuttingDown) { - if (code !== 0) { - console.error( - chalk.red( - `āœ— Runtime process exited unexpectedly with code ${code}`, - ), - ); - if (signal) { - console.error(chalk.red(` Signal: ${signal}`)); - } - } else { - console.log(chalk.gray("Runtime process exited")); - } - this.process = null; - } - }); - - // Handle process errors - this.process.on("error", (error) => { - if ((error as Error & { code: string }).code === "ENOENT") { - console.error( - chalk.red("āœ— Failed to start runtime: tsx not found"), - chalk.yellow( - "\n Make sure tsx is installed: npm install -g tsx or pnpm add tsx", - ), - ); - } else { - console.error(chalk.red("āœ— Failed to start runtime process:"), error); - } - this.process = null; - }); - - // Give the process a moment to start - await new Promise((resolve) => setTimeout(resolve, 100)); - - if (!this.process || this.process.exitCode !== null) { - throw new Error("Failed to start runtime process"); - } - - console.log(chalk.green("āœ“ Runtime process started")); - } - - /** - * Stop the user's function process. - */ - public async stop(): Promise { - if (!this.process) { - return; - } - - this.isShuttingDown = true; - - if (this.verbose) { - console.log(chalk.gray("Stopping runtime process...")); - } - - return new Promise((resolve) => { - if (!this.process) { - resolve(); - return; - } - - // Set a timeout to force kill if graceful shutdown fails - const killTimeout = setTimeout(() => { - if (this.process) { - console.log(chalk.yellow("āš ļø Force killing runtime process")); - this.process.kill("SIGKILL"); - } - }, 5000); - - this.process.on("exit", () => { - clearTimeout(killTimeout); - this.process = null; - console.log(chalk.green("āœ“ Runtime process stopped")); - resolve(); - }); - - // Try graceful shutdown first - this.process.kill("SIGTERM"); - }); - } - - /** - * Check if the process is currently running. - */ - public isRunning(): boolean { - return this.process !== null && this.process.exitCode === null; - } -} diff --git a/src/cli/dev/server.test.ts b/src/cli/dev/server.test.ts deleted file mode 100644 index 787fd80..0000000 --- a/src/cli/dev/server.test.ts +++ /dev/null @@ -1,759 +0,0 @@ -import { describe, it, beforeEach } from "node:test"; -import assert from "node:assert"; -import { IncomingMessage, ServerResponse } from "node:http"; -import { Socket } from "node:net"; - -import { type RequestHandlerDeps, handleRequest } from "./server.js"; -import type { IInvocationBridge } from "./bridge.js"; -import type { IRemoteBrowserManager } from "./browser-manager.js"; -import type { IRequestHandlers } from "./handlers/index.js"; - -/** - * Creates a mock implementation of IRequestHandlers - */ -class MockHandlers implements IRequestHandlers { - public handleInvocationNextCalls: Array<[IncomingMessage, ServerResponse]> = - []; - public handleFunctionInvokeCalls: Array< - [IncomingMessage, ServerResponse, string] - > = []; - public handleInvocationResponseCalls: Array< - [IncomingMessage, ServerResponse, string] - > = []; - public handleInvocationErrorCalls: Array< - [IncomingMessage, ServerResponse, string] - > = []; - - private handleInvocationNextImpl?: ( - req: IncomingMessage, - res: ServerResponse, - ) => Promise; - private handleFunctionInvokeImpl?: ( - req: IncomingMessage, - res: ServerResponse, - functionName: string, - ) => Promise; - private handleInvocationResponseImpl?: ( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ) => Promise; - private handleInvocationErrorImpl?: ( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ) => Promise; - - async handleInvocationNext( - req: IncomingMessage, - res: ServerResponse, - ): Promise { - this.handleInvocationNextCalls.push([req, res]); - if (this.handleInvocationNextImpl) { - await this.handleInvocationNextImpl(req, res); - } - } - - async handleFunctionInvoke( - req: IncomingMessage, - res: ServerResponse, - functionName: string, - ): Promise { - this.handleFunctionInvokeCalls.push([req, res, functionName]); - if (this.handleFunctionInvokeImpl) { - await this.handleFunctionInvokeImpl(req, res, functionName); - } - } - - async handleInvocationResponse( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ): Promise { - this.handleInvocationResponseCalls.push([req, res, requestId]); - if (this.handleInvocationResponseImpl) { - await this.handleInvocationResponseImpl(req, res, requestId); - } - } - - async handleInvocationError( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ): Promise { - this.handleInvocationErrorCalls.push([req, res, requestId]); - if (this.handleInvocationErrorImpl) { - await this.handleInvocationErrorImpl(req, res, requestId); - } - } - - // Methods to set custom implementations for testing specific behaviors - setHandleInvocationNextImpl( - impl: (req: IncomingMessage, res: ServerResponse) => Promise, - ) { - this.handleInvocationNextImpl = impl; - } - - setHandleFunctionInvokeImpl( - impl: ( - req: IncomingMessage, - res: ServerResponse, - functionName: string, - ) => Promise, - ) { - this.handleFunctionInvokeImpl = impl; - } - - setHandleInvocationResponseImpl( - impl: ( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ) => Promise, - ) { - this.handleInvocationResponseImpl = impl; - } - - setHandleInvocationErrorImpl( - impl: ( - req: IncomingMessage, - res: ServerResponse, - requestId: string, - ) => Promise, - ) { - this.handleInvocationErrorImpl = impl; - } - - reset() { - this.handleInvocationNextCalls = []; - this.handleFunctionInvokeCalls = []; - this.handleInvocationResponseCalls = []; - this.handleInvocationErrorCalls = []; - delete this.handleInvocationNextImpl; - delete this.handleFunctionInvokeImpl; - delete this.handleInvocationResponseImpl; - delete this.handleInvocationErrorImpl; - } -} - -/** - * Creates a mock implementation of IInvocationBridge - */ -class MockBridge implements IInvocationBridge { - public setSessionCleanupCallbackCalls: Array< - [(sessionId: string) => Promise] - > = []; - public holdNextConnectionCalls: Array<[ServerResponse]> = []; - public triggerInvocationCalls: Array< - [string, unknown, unknown, ServerResponse] - > = []; - public completeWithSuccessCalls: Array<[string, unknown]> = []; - public completeWithErrorCalls: Array<[string, unknown]> = []; - - setSessionCleanupCallback( - callback: (sessionId: string) => Promise, - ): void { - this.setSessionCleanupCallbackCalls.push([callback]); - } - - holdNextConnection(res: ServerResponse): void { - this.holdNextConnectionCalls.push([res]); - } - - triggerInvocation( - functionName: string, - params: unknown, - context: unknown, - clientRes: ServerResponse, - ): boolean { - this.triggerInvocationCalls.push([ - functionName, - params, - context, - clientRes, - ]); - return true; - } - - completeWithSuccess(requestId: string, result: unknown): boolean { - this.completeWithSuccessCalls.push([requestId, result]); - return true; - } - - completeWithError(requestId: string, error: unknown): boolean { - this.completeWithErrorCalls.push([requestId, error]); - return true; - } - - isReady(): boolean { - return true; - } - - hasActiveInvocation(): boolean { - return false; - } - - getCurrentRequestId(): string | null { - return null; - } - - isRuntimeConnected(): boolean { - return true; - } - - reset() { - this.setSessionCleanupCallbackCalls = []; - this.holdNextConnectionCalls = []; - this.triggerInvocationCalls = []; - this.completeWithSuccessCalls = []; - this.completeWithErrorCalls = []; - } -} - -/** - * Creates a mock implementation of IRemoteBrowserManager - */ -class MockBrowserManager implements IRemoteBrowserManager { - public initializeCalls: Array<[]> = []; - public createSessionCalls: Array<[unknown]> = []; - public closeSessionCalls: Array<[string]> = []; - - async initialize(): Promise { - this.initializeCalls.push([]); - } - - async createSession( - config?: unknown, - ): Promise<{ id: string; connectUrl: string }> { - this.createSessionCalls.push([config]); - return { id: "test-session-id", connectUrl: "ws://localhost:9222" }; - } - - async closeSession(sessionId: string): Promise { - this.closeSessionCalls.push([sessionId]); - } - - isInitialized(): boolean { - return true; - } - - reset() { - this.initializeCalls = []; - this.createSessionCalls = []; - this.closeSessionCalls = []; - } -} - -/** - * Creates a mock ServerResponse with tracking for all method calls - */ -class MockServerResponse extends ServerResponse { - public setHeaderCalls: Array<[string, string | number | string[]]> = []; - public writeHeadCalls: Array<[number, unknown?]> = []; - public endCalls: Array<[unknown?]> = []; - public writeCalls: Array<[unknown]> = []; - - constructor(req: IncomingMessage) { - super(req); - } - - override setHeader(name: string, value: string | number | string[]): this { - this.setHeaderCalls.push([name, value]); - return this; - } - - override writeHead(statusCode: number, headers?: unknown): this { - this.writeHeadCalls.push([statusCode, headers]); - return this; - } - - override end(chunk?: unknown): this { - this.endCalls.push([chunk]); - return this; - } - - override write(chunk: unknown): boolean { - this.writeCalls.push([chunk]); - return true; - } - - reset() { - this.setHeaderCalls = []; - this.writeHeadCalls = []; - this.endCalls = []; - this.writeCalls = []; - } -} - -describe("handleRequest", () => { - let req: IncomingMessage; - let res: MockServerResponse; - let deps: RequestHandlerDeps; - let socket: Socket; - let mockHandlers: MockHandlers; - let mockBridge: MockBridge; - let mockBrowserManager: MockBrowserManager; - - beforeEach(() => { - // Create mock socket - socket = new Socket(); - - // Create mock request - req = new IncomingMessage(socket); - req.headers = { host: "localhost:3000" }; - - // Create mock response - res = new MockServerResponse(req); - - // Create mock implementations - mockHandlers = new MockHandlers(); - mockBridge = new MockBridge(); - mockBrowserManager = new MockBrowserManager(); - - // Create deps with mock implementations - deps = { - handlers: mockHandlers, - bridge: mockBridge, - browserManager: mockBrowserManager, - }; - }); - - describe("CORS Headers", () => { - it("should set CORS headers for all requests", async () => { - req.method = "GET"; - req.url = "/unknown"; - - await handleRequest(req, res, deps); - - assert.ok(res.setHeaderCalls.length > 0); - assert.ok( - res.setHeaderCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify(["Access-Control-Allow-Origin", "*"]), - ), - "Expected CORS origin header", - ); - assert.ok( - res.setHeaderCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - "Access-Control-Allow-Methods", - "GET, POST, OPTIONS", - ]), - ), - "Expected CORS methods header", - ); - assert.ok( - res.setHeaderCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify(["Access-Control-Allow-Headers", "Content-Type"]), - ), - "Expected CORS headers header", - ); - }); - - it("should handle OPTIONS preflight requests", async () => { - req.method = "OPTIONS"; - req.url = "/any/path"; - - await handleRequest(req, res, deps); - - assert.ok( - res.writeHeadCalls.some( - (call) => JSON.stringify(call) === JSON.stringify([200]), - ), - "Expected 200 status code", - ); - assert.ok( - res.endCalls.some( - (call) => JSON.stringify(call) === JSON.stringify([undefined]), - ), - "Expected end call with undefined", - ); - }); - }); - - describe("Route: GET /2018-06-01/runtime/invocation/next", () => { - it("should call handleInvocationNext for the correct route", async () => { - req.method = "GET"; - req.url = "/2018-06-01/runtime/invocation/next"; - - await handleRequest(req, res, deps); - - assert.strictEqual(mockHandlers.handleInvocationNextCalls.length, 1); - assert.strictEqual(mockHandlers.handleInvocationNextCalls[0]?.[0], req); - assert.strictEqual(mockHandlers.handleInvocationNextCalls[0]?.[1], res); - }); - }); - - describe("Route: POST /v1/functions/:name/invoke", () => { - it("should call handleFunctionInvoke with correct function name", async () => { - req.method = "POST"; - req.url = "/v1/functions/myFunction/invoke"; - - await handleRequest(req, res, deps); - - assert.strictEqual(mockHandlers.handleFunctionInvokeCalls.length, 1); - assert.strictEqual(mockHandlers.handleFunctionInvokeCalls[0]?.[0], req); - assert.strictEqual(mockHandlers.handleFunctionInvokeCalls[0]?.[1], res); - assert.strictEqual( - mockHandlers.handleFunctionInvokeCalls[0]?.[2], - "myFunction", - ); - }); - - it("should handle function names with special characters", async () => { - req.method = "POST"; - req.url = "/v1/functions/my-function_123/invoke"; - - await handleRequest(req, res, deps); - - assert.strictEqual(mockHandlers.handleFunctionInvokeCalls.length, 1); - assert.strictEqual( - mockHandlers.handleFunctionInvokeCalls[0]?.[2], - "my-function_123", - ); - }); - }); - - describe("Route: POST /2018-06-01/runtime/invocation/:requestId/response", () => { - it("should call handleInvocationResponse with correct request ID", async () => { - req.method = "POST"; - req.url = "/2018-06-01/runtime/invocation/req-123/response"; - - await handleRequest(req, res, deps); - - assert.strictEqual(mockHandlers.handleInvocationResponseCalls.length, 1); - assert.strictEqual( - mockHandlers.handleInvocationResponseCalls[0]?.[0], - req, - ); - assert.strictEqual( - mockHandlers.handleInvocationResponseCalls[0]?.[1], - res, - ); - assert.strictEqual( - mockHandlers.handleInvocationResponseCalls[0]?.[2], - "req-123", - ); - }); - - it("should handle request IDs with UUIDs", async () => { - const uuid = "550e8400-e29b-41d4-a716-446655440000"; - req.method = "POST"; - req.url = `/2018-06-01/runtime/invocation/${uuid}/response`; - - await handleRequest(req, res, deps); - - assert.strictEqual(mockHandlers.handleInvocationResponseCalls.length, 1); - assert.strictEqual( - mockHandlers.handleInvocationResponseCalls[0]?.[2], - uuid, - ); - }); - }); - - describe("Route: POST /2018-06-01/runtime/invocation/:requestId/error", () => { - it("should call handleInvocationError with correct request ID", async () => { - req.method = "POST"; - req.url = "/2018-06-01/runtime/invocation/req-456/error"; - - await handleRequest(req, res, deps); - - assert.strictEqual(mockHandlers.handleInvocationErrorCalls.length, 1); - assert.strictEqual(mockHandlers.handleInvocationErrorCalls[0]?.[0], req); - assert.strictEqual(mockHandlers.handleInvocationErrorCalls[0]?.[1], res); - assert.strictEqual( - mockHandlers.handleInvocationErrorCalls[0]?.[2], - "req-456", - ); - }); - }); - - describe("Unknown Routes", () => { - it("should return 404 for unknown GET routes", async () => { - req.method = "GET"; - req.url = "/unknown/route"; - - await handleRequest(req, res, deps); - - assert.ok( - res.writeHeadCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - 404, - { - "Content-Type": "application/json", - }, - ]), - ), - "Expected 404 with JSON content type", - ); - assert.ok( - res.endCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([JSON.stringify({ error: "Not found" })]), - ), - "Expected Not found error", - ); - }); - - it("should return 404 for unknown POST routes", async () => { - req.method = "POST"; - req.url = "/api/v2/something"; - - await handleRequest(req, res, deps); - - assert.ok( - res.writeHeadCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - 404, - { - "Content-Type": "application/json", - }, - ]), - ), - "Expected 404 with JSON content type", - ); - assert.ok( - res.endCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([JSON.stringify({ error: "Not found" })]), - ), - "Expected Not found error", - ); - }); - - it("should return 404 for incorrect method on known routes", async () => { - req.method = "DELETE"; - req.url = "/2018-06-01/runtime/invocation/next"; - - await handleRequest(req, res, deps); - - assert.ok( - res.writeHeadCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - 404, - { - "Content-Type": "application/json", - }, - ]), - ), - "Expected 404 with JSON content type", - ); - assert.ok( - res.endCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([JSON.stringify({ error: "Not found" })]), - ), - "Expected Not found error", - ); - }); - }); - - describe("Error Handling", () => { - it("should handle errors thrown by handlers", async () => { - const error = new Error("Handler error"); - mockHandlers.setHandleInvocationNextImpl(async () => { - throw error; - }); - - req.method = "GET"; - req.url = "/2018-06-01/runtime/invocation/next"; - - await handleRequest(req, res, deps); - - assert.ok( - res.writeHeadCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - 500, - { - "Content-Type": "application/json", - }, - ]), - ), - "Expected 500 with JSON content type", - ); - assert.ok( - res.endCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - JSON.stringify({ error: "Internal server error" }), - ]), - ), - "Expected Internal server error", - ); - }); - - it("should handle malformed URLs gracefully", async () => { - req.method = "GET"; - req.url = "//malformed//url//"; - - await handleRequest(req, res, deps); - - // Should still set CORS headers - assert.ok( - res.setHeaderCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify(["Access-Control-Allow-Origin", "*"]), - ), - "Expected CORS origin header even with malformed URL", - ); - // Should return 404 as it doesn't match any route - assert.ok( - res.writeHeadCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - 404, - { - "Content-Type": "application/json", - }, - ]), - ), - "Expected 404 with JSON content type", - ); - }); - - it("should handle missing host header", async () => { - req.headers = {}; - req.method = "GET"; - req.url = "/2018-06-01/runtime/invocation/next"; - - await handleRequest(req, res, deps); - - // Should still work without host header - assert.strictEqual(mockHandlers.handleInvocationNextCalls.length, 1); - assert.strictEqual(mockHandlers.handleInvocationNextCalls[0]?.[0], req); - assert.strictEqual(mockHandlers.handleInvocationNextCalls[0]?.[1], res); - }); - }); - - describe("Edge Cases", () => { - it("should handle empty URL", async () => { - req.method = "GET"; - req.url = ""; - - await handleRequest(req, res, deps); - - assert.ok( - res.writeHeadCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - 404, - { - "Content-Type": "application/json", - }, - ]), - ), - "Expected 404 with JSON content type", - ); - assert.ok( - res.endCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([JSON.stringify({ error: "Not found" })]), - ), - "Expected Not found error", - ); - }); - - it("should handle undefined URL", async () => { - req.method = "GET"; - req.url = undefined; - - await handleRequest(req, res, deps); - - assert.ok( - res.writeHeadCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - 404, - { - "Content-Type": "application/json", - }, - ]), - ), - "Expected 404 with JSON content type", - ); - assert.ok( - res.endCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([JSON.stringify({ error: "Not found" })]), - ), - "Expected Not found error", - ); - }); - - it("should handle undefined method (defaults to GET)", async () => { - req.method = undefined; - req.url = "/unknown"; - - await handleRequest(req, res, deps); - - assert.ok( - res.writeHeadCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([ - 404, - { - "Content-Type": "application/json", - }, - ]), - ), - "Expected 404 with JSON content type", - ); - assert.ok( - res.endCalls.some( - (call) => - JSON.stringify(call) === - JSON.stringify([JSON.stringify({ error: "Not found" })]), - ), - "Expected Not found error", - ); - }); - - it("should handle URLs with query parameters", async () => { - req.method = "POST"; - req.url = "/v1/functions/testFunc/invoke?param1=value1¶m2=value2"; - - await handleRequest(req, res, deps); - - assert.strictEqual(mockHandlers.handleFunctionInvokeCalls.length, 1); - assert.strictEqual( - mockHandlers.handleFunctionInvokeCalls[0]?.[2], - "testFunc", - ); - }); - - it("should handle URLs with hash fragments", async () => { - req.method = "GET"; - req.url = "/2018-06-01/runtime/invocation/next#fragment"; - - await handleRequest(req, res, deps); - - assert.strictEqual(mockHandlers.handleInvocationNextCalls.length, 1); - assert.strictEqual(mockHandlers.handleInvocationNextCalls[0]?.[0], req); - assert.strictEqual(mockHandlers.handleInvocationNextCalls[0]?.[1], res); - }); - }); -}); diff --git a/src/cli/dev/server.ts b/src/cli/dev/server.ts deleted file mode 100644 index e3bae76..0000000 --- a/src/cli/dev/server.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { createServer, Server, IncomingMessage, ServerResponse } from "http"; -import chalk from "chalk"; -import { type IInvocationBridge } from "./bridge.js"; -import { type IRemoteBrowserManager } from "./browser-manager.js"; -import { type IRequestHandlers } from "./handlers/index.js"; - -export interface ServerOptions { - port: number; - host: string; - bridge: IInvocationBridge; - browserManager: IRemoteBrowserManager; - handlers: IRequestHandlers; -} - -export interface RequestHandlerDeps { - bridge: IInvocationBridge; - browserManager: IRemoteBrowserManager; - handlers: IRequestHandlers; -} - -/** - * Main request handler for the dev server - * Extracted for testability - */ -export async function handleRequest( - req: IncomingMessage, - res: ServerResponse, - deps: RequestHandlerDeps, -): Promise { - const { handlers } = deps; - const url = new URL(req.url || "", `http://${req.headers.host}`); - const method = req.method || "GET"; - const path = url.pathname; - - console.log(chalk.gray(`[${method}] ${path}`)); - - // Set CORS headers for local development - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); - - // Handle preflight requests - if (method === "OPTIONS") { - res.writeHead(200); - res.end(); - return; - } - - try { - // Route: GET / (healthcheck) - if (method === "GET" && path === "/") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - return; - } - - // Route: GET /2018-06-01/runtime/invocation/next - if (method === "GET" && path === "/2018-06-01/runtime/invocation/next") { - await handlers.handleInvocationNext(req, res); - return; - } - - // Route: POST /v1/functions/:name/invoke - const invokeMatch = path.match(/^\/v1\/functions\/([^/]+)\/invoke$/); - if (method === "POST" && invokeMatch && invokeMatch[1]) { - const functionName = invokeMatch[1]; - await handlers.handleFunctionInvoke(req, res, functionName); - return; - } - - // Route: POST /2018-06-01/runtime/invocation/:requestId/response - const responseMatch = path.match( - /^\/2018-06-01\/runtime\/invocation\/([^/]+)\/response$/, - ); - if (method === "POST" && responseMatch && responseMatch[1]) { - const requestId = responseMatch[1]; - await handlers.handleInvocationResponse(req, res, requestId); - return; - } - - // Route: POST /2018-06-01/runtime/invocation/:requestId/error - const errorMatch = path.match( - /^\/2018-06-01\/runtime\/invocation\/([^/]+)\/error$/, - ); - if (method === "POST" && errorMatch && errorMatch[1]) { - const requestId = errorMatch[1]; - await handlers.handleInvocationError(req, res, requestId); - return; - } - - // 404 for unknown routes - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Not found" })); - } catch (error) { - console.error(chalk.red("Server error:"), error); - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Internal server error" })); - } -} - -export async function startServer(options: ServerOptions): Promise { - const { port, host, bridge, browserManager, handlers } = options; - - const server = createServer( - async (req: IncomingMessage, res: ServerResponse) => { - await handleRequest(req, res, { bridge, browserManager, handlers }); - }, - ); - - return new Promise((resolve, reject) => { - server.listen(port, host, () => { - resolve(server); - }); - - server.on("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EADDRINUSE") { - reject(new Error(`Port ${port} is already in use`)); - } else if (error.code === "EACCES") { - reject(new Error(`Permission denied to bind to port ${port}`)); - } else { - reject(error); - } - }); - }); -} diff --git a/src/cli/init/index.ts b/src/cli/init/index.ts index 4e3373b..aa59fb1 100644 --- a/src/cli/init/index.ts +++ b/src/cli/init/index.ts @@ -1,303 +1,44 @@ -import { execSync } from "child_process"; -import { - existsSync, - readFileSync, - writeFileSync, - copyFileSync, - mkdirSync, -} from "fs"; -import { join, resolve } from "path"; -import { fileURLToPath } from "url"; -import { dirname } from "path"; import chalk from "chalk"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +import { + createFunctionProject, + type CreateFunctionProjectOptions, +} from "../../core/index.js"; -interface InitOptions { +export interface InitOptions { projectName: string; packageManager?: "npm" | "pnpm"; } -export async function init(options: InitOptions) { - // Validate project name - if (!isValidProjectName(options.projectName)) { - throw new Error( - `Invalid project name "${options.projectName}". Project names must start with a letter and contain only letters, numbers, hyphens, and underscores.`, - ); - } - - const targetDir = resolve(process.cwd(), options.projectName); - - // Check if directory already exists - if (existsSync(targetDir)) { - throw new Error( - `Directory "${options.projectName}" already exists. Please choose a different name or delete the existing directory.`, - ); - } - +export async function init(options: InitOptions): Promise { console.log( chalk.cyan( `šŸš€ Creating new Browserbase Functions project: ${chalk.bold(options.projectName)}`, ), ); - - // Create the project directory - mkdirSync(targetDir, { recursive: true }); - - try { - // Step 1: Check prerequisites - checkPrerequisites(); - - // Step 2: Initialize git repository - if (!existsSync(join(targetDir, ".git"))) { - console.log(chalk.gray("Initializing git repository...")); - execSync("git init", { cwd: targetDir, stdio: "pipe" }); - console.log(chalk.green("āœ“ Git repository initialized")); - } else { - console.log(chalk.yellow("āœ“ Git repository already exists")); - } - - // Step 3: Create .gitignore file - createGitignoreFile(targetDir); - - // Step 4: Initialize package.json - if (!existsSync(join(targetDir, "package.json"))) { - console.log(chalk.gray("Creating package.json...")); - execSync("pnpm init", { cwd: targetDir, stdio: "pipe" }); - console.log(chalk.green("āœ“ package.json created")); - } else { - console.log(chalk.yellow("āœ“ package.json already exists")); - } - - // Step 5: Detect and update package manager - const packageManager = detectPackageManager(options.packageManager); - updatePackageManager(targetDir, packageManager); - - // Step 6: Install dependencies - console.log(chalk.gray("Installing dependencies...")); - installDependencies(targetDir, packageManager); - console.log(chalk.green("āœ“ Dependencies installed")); - - // Step 7: Create .env file - createEnvFile(targetDir); - - // Step 8: Initialize TypeScript configuration - if (!existsSync(join(targetDir, "tsconfig.json"))) { - console.log(chalk.gray("Initializing TypeScript configuration...")); - execSync(`${packageManager === "pnpm" ? "pnpm" : "npx"} tsc --init`, { - cwd: targetDir, - stdio: "pipe", - }); - - // Update tsconfig.json with recommended settings - updateTsConfig(targetDir); - console.log(chalk.green("āœ“ TypeScript configuration created")); - } else { - console.log(chalk.yellow("āœ“ TypeScript configuration already exists")); - } - - // Step 9: Create starter function - createStarterFunction(targetDir); - - // Success message - console.log(""); - console.log(chalk.green.bold("✨ Project initialized successfully!")); - console.log(""); - 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 and project ID to .env"), - ); - console.log(chalk.gray("3. 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.white( - ` ${packageManager === "pnpm" ? "pnpm" : "npx"} bb publish index.ts`, - ), - ); - console.log(""); - console.log(chalk.gray("Learn more at https://browserbase.com/docs")); - } catch (error) { - console.error( - chalk.red("āŒ Initialization failed:"), - error instanceof Error ? error.message : error, - ); - process.exit(1); - } -} - -function checkPrerequisites() { - const requiredCommands = [ - { command: "node --version", name: "Node.js" }, - { command: "pnpm --version", name: "pnpm" }, - { command: "git --version", name: "git" }, - ]; - - for (const { command, name } of requiredCommands) { - try { - execSync(command, { stdio: "pipe" }); - } catch { - throw new Error( - `${name} is not installed. Please install ${name} and try again.`, - ); - } - } -} - -function detectPackageManager(preferred?: "npm" | "pnpm"): "npm" | "pnpm" { - if (preferred) { - return preferred; - } - - // Check if running via pnpm dlx - const userAgent = process.env["npm_config_user_agent"]; - if (userAgent && userAgent.includes("pnpm")) { - return "pnpm"; - } - - // Default to pnpm since it's required anyway - return "pnpm"; -} - -function updatePackageManager( - targetDir: string, - packageManager: "npm" | "pnpm", -) { - const packageJsonPath = join(targetDir, "package.json"); - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); - - // Get the version of the package manager - let version: string; - try { - if (packageManager === "pnpm") { - version = execSync("pnpm --version", { stdio: "pipe" }).toString().trim(); - packageJson.packageManager = `pnpm@${version}`; - } else { - version = execSync("npm --version", { stdio: "pipe" }).toString().trim(); - packageJson.packageManager = `npm@${version}`; - } - } catch { - // If we can't get the version, use a recent stable version - packageJson.packageManager = - packageManager === "pnpm" ? "pnpm@9.0.0" : "npm@10.0.0"; - } - - // Add "type": "module" to support ES modules - packageJson.type = "module"; - - writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + const coreOptions: CreateFunctionProjectOptions = { + projectName: options.projectName, + onOutput(stream, text) { + (stream === "stderr" ? process.stderr : process.stdout).write(text); + }, + }; + if (options.packageManager !== undefined) { + coreOptions.packageManager = options.packageManager; + } + const result = await createFunctionProject(coreOptions); + + console.log(chalk.green.bold("\n✨ Project initialized successfully!\n")); + console.log(chalk.cyan("Next steps:")); + console.log(chalk.gray(`1. cd ${options.projectName}`)); + console.log(chalk.gray("2. Add your Browserbase API key to .env")); console.log( - chalk.green(`āœ“ Package manager set to ${packageJson.packageManager}`), + chalk.gray( + `3. ${result.packageManager === "pnpm" ? "pnpm" : "npm run"} dev`, + ), + ); + console.log( + chalk.gray( + `4. ${result.packageManager === "pnpm" ? "pnpm" : "npm run"} deploy`, + ), ); -} - -function installDependencies( - targetDir: string, - packageManager: "npm" | "pnpm", -) { - const installCmd = packageManager === "pnpm" ? "pnpm add" : "npm install"; - const installDevCmd = - packageManager === "pnpm" ? "pnpm add -D" : "npm install --save-dev"; - - // Install regular dependencies - console.log(chalk.gray(" Installing @browserbasehq/sdk-functions...")); - execSync(`${installCmd} @browserbasehq/sdk-functions`, { - cwd: targetDir, - stdio: "pipe", - }); - - console.log(chalk.gray(" Installing playwright-core...")); - execSync(`${installCmd} playwright-core`, { - cwd: targetDir, - stdio: "pipe", - }); - - console.log(chalk.gray(" Installing zod...")); - execSync(`${installCmd} zod`, { - cwd: targetDir, - stdio: "pipe", - }); - - // Install dev dependencies - console.log(chalk.gray(" Installing TypeScript and type definitions...")); - execSync(`${installDevCmd} typescript @types/node`, { - cwd: targetDir, - stdio: "pipe", - }); -} - -function createEnvFile(targetDir: string) { - const envPath = join(targetDir, ".env"); - if (!existsSync(envPath)) { - const templatePath = join(__dirname, "templates", ".env.template"); - copyFileSync(templatePath, envPath); - console.log(chalk.green("āœ“ .env file created")); - } else { - console.log(chalk.yellow("āœ“ .env file already exists")); - } -} - -function createGitignoreFile(targetDir: string) { - const gitignorePath = join(targetDir, ".gitignore"); - if (!existsSync(gitignorePath)) { - const templatePath = join(__dirname, "templates", ".gitignore.template"); - copyFileSync(templatePath, gitignorePath); - console.log(chalk.green("āœ“ .gitignore file created")); - } else { - console.log(chalk.yellow("āœ“ .gitignore file already exists")); - } -} - -function createStarterFunction(targetDir: string) { - const indexPath = join(targetDir, "index.ts"); - if (!existsSync(indexPath)) { - const templatePath = join( - __dirname, - "templates", - "starter-function.ts.template", - ); - copyFileSync(templatePath, indexPath); - console.log(chalk.green("āœ“ Starter function created (index.ts)")); - } else { - console.log(chalk.yellow("āœ“ index.ts already exists")); - } -} - -function updateTsConfig(targetDir: string) { - const tsConfigPath = join(targetDir, "tsconfig.json"); - - try { - const tsConfig = JSON.parse(readFileSync(tsConfigPath, "utf-8")); - - // Update with recommended settings for Browserbase functions - tsConfig.compilerOptions = { - ...tsConfig.compilerOptions, - target: "ES2022", - module: "NodeNext", - moduleResolution: "NodeNext", - esModuleInterop: true, - forceConsistentCasingInFileNames: true, - strict: true, - skipLibCheck: true, - resolveJsonModule: true, - }; - - writeFileSync(tsConfigPath, JSON.stringify(tsConfig, null, 2)); - } catch { - // If we can't parse/update, that's okay - the default tsc --init output will work - console.log(chalk.yellow(" Using default TypeScript configuration")); - } -} - -function isValidProjectName(name: string): boolean { - // Project name must start with a letter and contain only letters, numbers, hyphens, and underscores - const validNameRegex = /^[a-zA-Z][a-zA-Z0-9_-]*$/; - return validNameRegex.test(name); } diff --git a/src/cli/init/templates/.env.template b/src/cli/init/templates/.env.template deleted file mode 100644 index e7e6194..0000000 --- a/src/cli/init/templates/.env.template +++ /dev/null @@ -1,5 +0,0 @@ -# Browserbase Configuration -# Get your API key from https://browserbase.com - -# Your Browserbase API key -BROWSERBASE_API_KEY=your_api_key_here diff --git a/src/cli/init/templates/.gitignore.template b/src/cli/init/templates/.gitignore.template deleted file mode 100644 index 4ab7024..0000000 --- a/src/cli/init/templates/.gitignore.template +++ /dev/null @@ -1,18 +0,0 @@ -# Dependencies -node_modules/ - -# Environment variables -.env -.env.local - -# TypeScript build info -*.tsbuildinfo - -# OS files -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* -pnpm-debug.log* \ No newline at end of file diff --git a/src/cli/init/templates/starter-function.ts.template b/src/cli/init/templates/starter-function.ts.template deleted file mode 100644 index fd73548..0000000 --- a/src/cli/init/templates/starter-function.ts.template +++ /dev/null @@ -1,61 +0,0 @@ -import { defineFn } from "@browserbasehq/sdk-functions"; -import { chromium } from "playwright-core"; - -// 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, - }); - } - }); - - 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, - }; -}); - diff --git a/src/cli/invoke/index.ts b/src/cli/invoke/index.ts index 35e68d8..95a9b5c 100644 --- a/src/cli/invoke/index.ts +++ b/src/cli/invoke/index.ts @@ -1,27 +1,10 @@ import chalk from "chalk"; import { - loadBaseConfig, - apiGet, - apiPost, - pollUntil, - type InvocationStatus, - isTerminalInvocationStatus, -} from "../shared/index.js"; - -export interface InvocationResponse { - id: string; - functionId: string; - status: InvocationStatus; - params?: Record; - results?: Record; - sessionId: string; - createdAt: string; - updatedAt: string; - startedAt?: string; - endedAt?: string; - expiresAt?: string; -} + invokeFunction, + parseJsonArgument, + type InvocationResponse, +} from "../../core/index.js"; export interface InvokeOptions { functionId: string; @@ -31,166 +14,41 @@ export interface InvokeOptions { checkStatus?: string; } +export async function invoke(options: InvokeOptions): Promise { + console.log(chalk.bold.cyan("\nBrowserbase Functions - Invoke\n")); + const invocation = await invokeFunction({ + functionId: options.functionId, + params: parseJsonArgument(options.params, "--params"), + ...(options.apiUrl ? { baseUrl: options.apiUrl } : {}), + ...(options.checkStatus ? { checkStatus: options.checkStatus } : {}), + ...(options.noWait !== undefined ? { noWait: options.noWait } : {}), + onInvocationStatus(status, attempt) { + process.stdout.write( + `\r${chalk.gray(`Status: ${status.status}... (${attempt}/900)`)}`, + ); + }, + }); + + process.stdout.write("\r" + " ".repeat(70) + "\r"); + if (options.noWait) { + console.log(chalk.green("āœ“ Function invoked successfully")); + } else if (!options.checkStatus) { + console.log(chalk.green("āœ“ Invocation completed successfully")); + } + displayInvocationResult(invocation); +} + function displayInvocationResult(invocation: InvocationResponse): void { console.log(chalk.bold.cyan("\nšŸ“‹ Invocation Details")); console.log(chalk.gray("─".repeat(50))); - console.log(chalk.white(`Invocation ID: ${chalk.cyan(invocation.id)}`)); console.log(chalk.white(`Function ID: ${chalk.cyan(invocation.functionId)}`)); console.log(chalk.white(`Status: ${chalk.cyan(invocation.status)}`)); - if (invocation.sessionId) { console.log(chalk.white(`Session ID: ${chalk.cyan(invocation.sessionId)}`)); } - - if (invocation.startedAt) { - console.log( - chalk.white( - `Started: ${chalk.gray(new Date(invocation.startedAt).toLocaleString())}`, - ), - ); - } - - if (invocation.endedAt) { - console.log( - chalk.white( - `Ended: ${chalk.gray(new Date(invocation.endedAt).toLocaleString())}`, - ), - ); - - if (invocation.startedAt) { - const duration = - new Date(invocation.endedAt).getTime() - - new Date(invocation.startedAt).getTime(); - const seconds = (duration / 1000).toFixed(2); - console.log(chalk.white(`Duration: ${chalk.cyan(`${seconds}s`)}`)); - } - } - - if (invocation.results && Object.keys(invocation.results).length > 0) { + if (invocation.results !== undefined) { console.log(chalk.bold.cyan("\nšŸ“¦ Results")); - console.log(chalk.gray("─".repeat(50))); console.log(JSON.stringify(invocation.results, null, 2)); } } - -export async function invoke(options: InvokeOptions): Promise { - console.log(chalk.bold.cyan("\nBrowserbase Functions - Invoke\n")); - - try { - const config = loadBaseConfig( - options.apiUrl ? { apiUrl: options.apiUrl } : undefined, - ); - - // If --check-status flag is provided, just check status of existing invocation - if (options.checkStatus) { - console.log( - chalk.gray(`Checking status for invocation: ${options.checkStatus}`), - ); - - const status = await apiGet( - config, - `/v1/functions/invocations/${options.checkStatus}`, - ); - - if (!status) { - console.error(chalk.red("\nāœ— Failed to get invocation status")); - process.exit(1); - } - - displayInvocationResult(status); - return; - } - - // Parse params if provided - let params: Record = {}; - if (options.params) { - try { - params = JSON.parse(options.params); - } catch { - console.error( - chalk.red("Error: Invalid JSON provided for --params flag"), - ); - console.log(chalk.gray('Example: --params \'{"key": "value"}\'')); - process.exit(1); - } - } - - console.log(chalk.gray(`Function ID: ${options.functionId}`)); - console.log(chalk.gray(`API URL: ${config.apiUrl}`)); - if (Object.keys(params).length > 0) { - console.log(chalk.gray(`Params: ${JSON.stringify(params)}`)); - } - - // Invoke the function - console.log(chalk.cyan("\nInvoking function...")); - const endpoint = `/v1/functions/${options.functionId}/invoke`; - console.log(chalk.gray(`POST ${config.apiUrl}${endpoint}`)); - - const result = await apiPost(config, endpoint, { - params, - }); - - if (!result.success) { - console.error(chalk.red(`\nInvoke failed: ${result.error}`)); - process.exit(1); - } - - console.log(chalk.green("āœ“ Function invoked successfully")); - console.log(chalk.gray(`Invocation ID: ${result.data.id}`)); - - // If --no-wait flag is set, just return the invocation ID - if (options.noWait) { - console.log(chalk.bold.green("\nāœ“ Function invoked!")); - console.log(chalk.gray(`\nInvocation ID: ${result.data.id}`)); - console.log( - chalk.cyan( - `\nTo check status later, run:\n bb invoke ${options.functionId} --check-status ${result.data.id}`, - ), - ); - return; - } - - // Poll for completion - const finalStatus = await pollUntil( - () => - apiGet( - config, - `/v1/functions/invocations/${result.data.id}`, - ), - (status) => isTerminalInvocationStatus(status.status), - (status) => status.status, - { - intervalMs: 1000, - maxAttempts: 900, - waitingMessage: "Waiting for invocation to complete...", - timeoutMessage: - "Invocation is still running after maximum wait time. Use 'bb invoke --check-status ' to check later.", - }, - ); - - if (!finalStatus) { - console.error(chalk.red("\nāœ— Failed to get final invocation status")); - process.exit(1); - } - - if (finalStatus.status === "COMPLETED") { - console.log(chalk.green("āœ“ Invocation completed successfully")); - } else if (finalStatus.status === "FAILED") { - console.error(chalk.red("āœ— Invocation failed")); - } - - displayInvocationResult(finalStatus); - - if (finalStatus.status === "FAILED") { - process.exit(1); - } - } catch (error: unknown) { - console.error( - chalk.red( - `\nāœ— Invoke failed: ${(error as { message?: string }).message ?? "unknown error"}`, - ), - ); - process.exit(1); - } -} diff --git a/src/cli/publish/api-client.ts b/src/cli/publish/api-client.ts deleted file mode 100644 index a4320f5..0000000 --- a/src/cli/publish/api-client.ts +++ /dev/null @@ -1,232 +0,0 @@ -import chalk from "chalk"; - -import { type PublishConfig } from "./config.js"; -import { - parseErrorResponse, - pollUntil, - type BuildStatus, - isTerminalBuildStatus, -} from "../shared/index.js"; - -export interface BuildMetadata { - entrypoint: string; -} - -export interface UploadResult { - buildId?: string; - success: boolean; - message?: string; -} - -export interface FunctionCreatedVersion { - id: string; - functionId: string; - functionBuildId: string; - sessionCreateParams?: Record; - createdAt: string; - updatedAt: string; -} - -export interface BuiltFunction { - id: string; - name: string; - createdVersion: FunctionCreatedVersion; - createdAt: string; - updatedAt: string; -} - -export interface BuildStatusResponse { - id: string; - status: BuildStatus; - request: { - entrypoint: string; - }; - createdAt: string; - updatedAt: string; - startedAt: string; - endedAt?: string; - expiresAt: string; - builtFunctions?: BuiltFunction[]; -} - -export async function uploadBuild( - config: PublishConfig, - archiveBuffer: Buffer, - options?: { - dryRun?: boolean; - }, -): Promise { - if (options?.dryRun) { - console.log(chalk.cyan("\n[Dry run] Would upload to:")); - console.log(chalk.gray(` URL: ${config.apiUrl}/v1/functions/builds`)); - console.log(chalk.gray(` Entrypoint: ${config.entrypoint}`)); - console.log( - chalk.gray( - ` Archive size: ${(archiveBuffer.length / (1024 * 1024)).toFixed(2)} MB`, - ), - ); - return { - success: true, - message: "Dry run completed successfully", - }; - } - - console.log(chalk.cyan("\nUploading build...")); - - try { - // Create form data - const formData = new FormData(); - - // Add metadata - const metadata: BuildMetadata = { - entrypoint: config.entrypoint, - }; - formData.append("metadata", JSON.stringify(metadata)); - - // Add archive file as a blob - const blob = new Blob([archiveBuffer], { type: "application/gzip" }); - formData.append("archive", blob, "archive.tar.gz"); - - // Make the request - const url = `${config.apiUrl}/v1/functions/builds`; - console.log(chalk.gray(`Uploading to: ${url}`)); - - const response = await fetch(url, { - method: "POST", - headers: { - "x-bb-api-key": config.apiKey, - }, - body: formData, - }); - - // Handle response - if (!response.ok) { - const errorMessage = await parseErrorResponse(response); - console.error(chalk.red(`Upload failed: ${errorMessage}`)); - return { - success: false, - message: errorMessage, - }; - } - - // Parse successful response - let responseData: { id?: string } = {}; - try { - const jsonResponse = await response.json(); - if (typeof jsonResponse === "object" && jsonResponse !== null) { - responseData = jsonResponse as { id?: string }; - } - } catch { - // Response might not be JSON - } - - if (!responseData.id) { - console.error( - chalk.red("Upload failed: No build ID received in response"), - ); - return { - success: false, - message: "No build ID received in response", - }; - } - - console.log(chalk.green("āœ“ Build uploaded successfully")); - console.log(chalk.gray(`Build ID: ${responseData.id}`)); - - return { - success: true, - buildId: responseData.id, - message: "Build uploaded successfully", - }; - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error occurred"; - console.error(chalk.red(`Upload error: ${errorMessage}`)); - - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "ECONNREFUSED" - ) { - console.log( - chalk.yellow( - `\nCannot connect to ${config.apiUrl}. Make sure the API server is running.`, - ), - ); - } - - return { - success: false, - message: errorMessage, - }; - } -} - -async function getBuildStatus( - config: PublishConfig, - buildId: string, -): Promise { - try { - const url = `${config.apiUrl}/v1/functions/builds/${buildId}`; - const response = await fetch(url, { - method: "GET", - headers: { - "x-bb-api-key": config.apiKey, - }, - }); - - if (!response.ok) { - console.error( - chalk.red(`Failed to get build status: HTTP ${response.status}`), - ); - return null; - } - - const data = await response.json(); - return data as BuildStatusResponse; - } catch (error) { - console.error( - chalk.red( - `Error fetching build status: ${error instanceof Error ? error.message : "Unknown error"}`, - ), - ); - return null; - } -} - -export async function pollBuildStatus( - config: PublishConfig, - buildId: string, - options?: { - intervalMs?: number; - maxAttempts?: number; - }, -): Promise { - console.log( - chalk.gray("(Builds typically take around 1 minute to complete)"), - ); - - const result = await pollUntil( - () => getBuildStatus(config, buildId), - (status) => isTerminalBuildStatus(status.status), - (status) => status.status, - { - intervalMs: options?.intervalMs ?? 2000, - maxAttempts: options?.maxAttempts ?? 100, - waitingMessage: "Waiting for build to complete...", - timeoutMessage: - "Build is still running after maximum wait time (~3 minutes). Please check the dashboard for the current build status.", - }, - ); - - if (result) { - if (result.status === "COMPLETED") { - console.log(chalk.green("āœ“ Build completed successfully")); - } else if (result.status === "FAILED") { - console.error(chalk.red("āœ— Build failed")); - } - } - - return result; -} diff --git a/src/cli/publish/archiver.ts b/src/cli/publish/archiver.ts deleted file mode 100644 index 4887a62..0000000 --- a/src/cli/publish/archiver.ts +++ /dev/null @@ -1,165 +0,0 @@ -import archiver from "archiver"; -import chalk from "chalk"; -import * as path from "node:path"; -import * as fs from "node:fs"; - -interface ArchiveResult { - buffer: Buffer; - size: number; - fileCount: number; -} - -function loadGitignorePatterns(workingDirectory: string): string[] { - const gitignorePath = path.join(workingDirectory, ".gitignore"); - const defaultPatterns = [ - "node_modules/**", - ".git/**", - ".env", - ".env.*", - "*.log", - ".DS_Store", - "dist/**", - "build/**", - "*.zip", - "*.tar", - "*.tar.gz", - ".vscode/**", - ".idea/**", - ]; - - if (!fs.existsSync(gitignorePath)) { - return defaultPatterns; - } - - try { - const gitignoreContent = fs.readFileSync(gitignorePath, "utf-8"); - const patterns = gitignoreContent - .split("\n") - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith("#")) - .map((pattern) => { - // Convert gitignore patterns to glob patterns for archiver - if (pattern.endsWith("/")) { - return `${pattern}**`; - } - return pattern; - }); - - return [...defaultPatterns, ...patterns]; - } catch (error: unknown) { - console.warn( - chalk.yellow( - error, - "Warning: Could not read .gitignore file, using defaults", - ), - ); - return defaultPatterns; - } -} - -export async function createArchive( - workingDirectory: string, - options?: { - dryRun?: boolean; - }, -): Promise { - return new Promise((resolve, reject) => { - console.log(chalk.cyan("Creating archive...")); - - const archive = archiver("tar", { - gzip: true, - gzipOptions: { level: 9 }, // Maximum compression - }); - - const chunks: Buffer[] = []; - let fileCount = 0; - - // Collect archive data in memory - archive.on("data", (chunk) => { - chunks.push(chunk); - }); - - // Track files being added - archive.on("entry", (entry) => { - if (!entry.stats?.isDirectory()) { - fileCount++; - if (options?.dryRun) { - const relativePath = path.relative(workingDirectory, entry.name); - console.log(chalk.gray(` + ${relativePath}`)); - } - } - }); - - archive.on("end", () => { - const buffer = Buffer.concat(chunks); - const sizeInMB = (buffer.length / (1024 * 1024)).toFixed(2); - - console.log( - chalk.green(`āœ“ Archive created: ${fileCount} files, ${sizeInMB} MB`), - ); - - resolve({ - buffer, - size: buffer.length, - fileCount, - }); - }); - - archive.on("error", (err) => { - console.error(chalk.red(`Archive error: ${err.message}`)); - reject(err); - }); - - archive.on("warning", (err) => { - if (err.code === "ENOENT") { - console.warn(chalk.yellow(`Warning: ${err.message}`)); - } else { - reject(err); - } - }); - - // Get ignore patterns - const ignorePatterns = loadGitignorePatterns(workingDirectory); - - if (options?.dryRun) { - console.log(chalk.gray("\nIgnoring patterns:")); - ignorePatterns.forEach((pattern) => { - console.log(chalk.gray(` - ${pattern}`)); - }); - console.log(chalk.gray("\nIncluding files:")); - } - - // Add directory contents with ignore patterns - archive.glob("**/*", { - cwd: workingDirectory, - ignore: ignorePatterns, - dot: true, // Include dotfiles (except those in ignore patterns) - follow: false, // Don't follow symlinks - }); - - // Finalize the archive - archive.finalize(); - }); -} - -export function validateArchiveSize( - size: number, - maxSizeMB: number = 50, -): void { - const sizeInMB = size / (1024 * 1024); - if (sizeInMB > maxSizeMB) { - console.error( - chalk.red( - `Error: Archive size (${sizeInMB.toFixed( - 2, - )} MB) exceeds maximum allowed size (${maxSizeMB} MB)`, - ), - ); - console.log( - chalk.gray( - "Consider adding more patterns to .gitignore to reduce archive size", - ), - ); - process.exit(1); - } -} diff --git a/src/cli/publish/config.ts b/src/cli/publish/config.ts deleted file mode 100644 index 1837095..0000000 --- a/src/cli/publish/config.ts +++ /dev/null @@ -1,60 +0,0 @@ -import chalk from "chalk"; -import * as fs from "node:fs"; -import * as path from "node:path"; - -import { - type BaseConfig, - requireApiKey, - getApiUrl, - validateApiKeyFormat, -} from "../shared/index.js"; - -/** - * Extended configuration for publish command. - */ -export interface PublishConfig extends BaseConfig { - entrypoint: string; - workingDirectory: string; -} - -export function loadConfig(options: { - entrypoint?: string; - apiUrl?: string; -}): PublishConfig { - const apiKey = requireApiKey(); - const apiUrl = getApiUrl(options.apiUrl); - - // Use provided entrypoint or default to main.ts - const entrypoint = options.entrypoint || "main.ts"; - - // Validate entrypoint exists - const entrypointPath = path.resolve(entrypoint); - if (!fs.existsSync(entrypointPath)) { - console.error( - chalk.red(`Error: Entrypoint file not found: ${entrypointPath}`), - ); - process.exit(1); - } - - // Validate entrypoint has valid extension - const ext = path.extname(entrypoint).toLowerCase(); - if (![".ts", ".js", ".mjs", ".mts"].includes(ext)) { - console.error( - chalk.red( - `Error: Invalid entrypoint extension: ${ext}. Must be .ts, .js, .mjs, or .mts`, - ), - ); - process.exit(1); - } - - return { - apiKey, - apiUrl, - entrypoint, - workingDirectory: process.cwd(), - }; -} - -export function validateConfig(config: PublishConfig): void { - validateApiKeyFormat(config.apiKey); -} diff --git a/src/cli/publish/index.ts b/src/cli/publish/index.ts index 98e71a2..91c8312 100644 --- a/src/cli/publish/index.ts +++ b/src/cli/publish/index.ts @@ -1,12 +1,9 @@ import chalk from "chalk"; -import { loadConfig, validateConfig } from "./config.js"; -import { createArchive, validateArchiveSize } from "./archiver.js"; import { - uploadBuild, - pollBuildStatus, + publishFunction as publishFunctionCore, type BuildStatusResponse, -} from "./api-client.js"; +} from "../../core/index.js"; export interface PublishOptions { entrypoint?: string; @@ -14,222 +11,56 @@ export interface PublishOptions { dryRun?: boolean; } +export async function publishFunction(options: PublishOptions): Promise { + console.log(chalk.bold.cyan("\nBrowserbase Functions - Publish\n")); + const result = await publishFunctionCore({ + entrypoint: options.entrypoint ?? "main.ts", + ...(options.apiUrl ? { baseUrl: options.apiUrl } : {}), + ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), + onBuildStatus(build, attempt) { + process.stdout.write( + `\r${chalk.gray(`Status: ${build.status}... (${attempt}/100)`)}`, + ); + }, + }); + + if (result.dryRun) { + console.log(chalk.yellow("[Dry run mode - no files uploaded]")); + console.log(chalk.gray(`Entrypoint: ${result.entrypoint}`)); + for (const file of result.files) { + console.log(chalk.gray(` + ${file}`)); + } + console.log(chalk.bold.green("\nāœ“ Dry run completed successfully!")); + return; + } + + process.stdout.write("\r" + " ".repeat(70) + "\r"); + console.log( + chalk.bold.green("šŸŽ‰ Function deployed and ready for invocation!"), + ); + displayBuildDetails(result.build); +} + function displayBuildDetails(build: BuildStatusResponse): void { console.log(chalk.bold.cyan("\nšŸ“¦ Build Details")); console.log(chalk.gray("─".repeat(50))); - - // Display basic build information console.log(chalk.white(`Build ID: ${chalk.cyan(build.id)}`)); console.log(chalk.white(`Status: ${chalk.green(build.status)}`)); - if (build.request?.entrypoint) { console.log( chalk.white(`Entrypoint: ${chalk.cyan(build.request.entrypoint)}`), ); } - - // Display timing information if available - if (build.startedAt) { - console.log( - chalk.white( - `Started: ${chalk.gray(new Date(build.startedAt).toLocaleString())}`, - ), - ); - } - if (build.endedAt) { - console.log( - chalk.white( - `Completed: ${chalk.gray(new Date(build.endedAt).toLocaleString())}`, - ), - ); - if (build.startedAt) { - const duration = - new Date(build.endedAt).getTime() - new Date(build.startedAt).getTime(); - const seconds = Math.floor(duration / 1000); - console.log(chalk.white(`Duration: ${chalk.cyan(`${seconds} seconds`)}`)); - } - } - if (build.expiresAt) { - console.log( - chalk.white( - `Expires: ${chalk.gray(new Date(build.expiresAt).toLocaleString())}`, - ), - ); - } - - // Display built functions - if (build.builtFunctions && build.builtFunctions.length > 0) { + if (build.builtFunctions?.length) { console.log(chalk.bold.cyan("\nšŸš€ Built Functions")); - console.log(chalk.gray("─".repeat(50))); - - build.builtFunctions.forEach((func, index) => { + for (const [index, func] of build.builtFunctions.entries()) { console.log(chalk.bold.white(`\n${index + 1}. ${func.name}`)); console.log(chalk.white(` Function ID: ${chalk.cyan(func.id)}`)); - - if (func.createdVersion) { + if (func.createdVersion?.id) { console.log( chalk.white(` Version ID: ${chalk.cyan(func.createdVersion.id)}`), ); - - // Display browser settings if available - if (func.createdVersion.sessionCreateParams) { - const params = func.createdVersion.sessionCreateParams; - const hasSettings = Object.keys(params).length > 0; - - if (hasSettings) { - console.log(chalk.white(` Browser Settings:`)); - Object.entries(params).forEach(([key, value]) => { - console.log( - chalk.gray(` - ${key}: ${JSON.stringify(value)}`), - ); - }); - } - } - } - }); - - console.log(chalk.bold.cyan("\n✨ Next Steps")); - console.log(chalk.gray("─".repeat(50))); - console.log(chalk.white("Your functions are ready to be invoked!")); - - build.builtFunctions.forEach((func) => { - console.log(chalk.white(`\nInvoke using the CLI:`)); - console.log(chalk.gray(`\n bb invoke ${func.id} --params '{}'`)); - - console.log(chalk.white(`\nOr using cURL:`)); - console.log(chalk.gray("\n curl --request POST \\")); - console.log( - chalk.gray( - ` --url https://api.browserbase.com/v1/functions/${func.id}/invoke \\`, - ), - ); - console.log( - chalk.gray(" --header 'Content-Type: application/json' \\"), - ); - console.log(chalk.gray(" --header 'x-bb-api-key: YOUR_API_KEY' \\")); - console.log(chalk.gray(" --data '{\"params\": {}}'")); - }); - } else { - console.log( - chalk.yellow( - "\nNo functions were built. Please check your entrypoint and function exports.", - ), - ); - } -} - -export async function publishFunction(options: PublishOptions): Promise { - console.log(chalk.bold.cyan("\nBrowserbase Functions - Publish\n")); - - try { - // Load and validate configuration - const configOptions: { entrypoint?: string; apiUrl?: string } = {}; - if (options.entrypoint !== undefined) { - configOptions.entrypoint = options.entrypoint; - } - if (options.apiUrl !== undefined) { - configOptions.apiUrl = options.apiUrl; - } - const config = loadConfig(configOptions); - - validateConfig(config); - - console.log(chalk.gray(`Working directory: ${config.workingDirectory}`)); - console.log(chalk.gray(`Entrypoint: ${config.entrypoint}`)); - console.log(chalk.gray(`API URL: ${config.apiUrl}`)); - - if (options.dryRun) { - console.log( - chalk.yellow("\n[Dry run mode - no files will be uploaded]\n"), - ); - } - - // Create archive - const archiveOptions: { dryRun?: boolean } = {}; - if (options.dryRun !== undefined) { - archiveOptions.dryRun = options.dryRun; - } - const archive = await createArchive( - config.workingDirectory, - archiveOptions, - ); - - // Validate archive size - validateArchiveSize(archive.size); - - // Upload build - const uploadOptions: { dryRun?: boolean } = {}; - if (options.dryRun !== undefined) { - uploadOptions.dryRun = options.dryRun; - } - const result = await uploadBuild(config, archive.buffer, uploadOptions); - - if (!result.success) { - console.error(chalk.red("\nāœ— Publish failed")); - process.exit(1); - } - - // Success! - if (options.dryRun) { - console.log(chalk.bold.green("\nāœ“ Dry run completed successfully!")); - console.log( - chalk.cyan( - "\nYour function would have been published. Run without --dry-run to publish.", - ), - ); - } else { - console.log(chalk.bold.green("\nāœ“ Function uploaded successfully!")); - - if (result.buildId) { - console.log(chalk.gray(`\nBuild ID: ${result.buildId}`)); - - // Poll for build status - const buildStatus = await pollBuildStatus(config, result.buildId); - - if (buildStatus?.status === "COMPLETED") { - console.log( - chalk.bold.green( - "\nšŸŽ‰ Your function has been deployed and is ready for invocation!", - ), - ); - - // Display detailed build information and next steps - displayBuildDetails(buildStatus); - } else if (buildStatus?.status === "FAILED") { - console.error(chalk.red("\nāœ— Build failed during processing")); - - // Still display build details for failed builds to help with debugging - if (buildStatus) { - displayBuildDetails(buildStatus); - } - - process.exit(1); - } else { - console.log( - chalk.yellow( - "\nBuild status could not be determined. Check the dashboard for updates.", - ), - ); - } - } else { - console.log( - chalk.cyan( - "\nYour function will be available for invocation once the build is processed.", - ), - ); } } - } catch (error: unknown) { - console.error( - chalk.red( - `\nāœ— Publish failed: ${(error as { message?: string }).message ?? "unknown error"}`, - ), - ); - - // if (error.stack && process.env.DEBUG) { - // console.error(chalk.gray(error.stack)); - // } - - process.exit(1); } } diff --git a/src/cli/shared/api-client.ts b/src/cli/shared/api-client.ts deleted file mode 100644 index 37a0689..0000000 --- a/src/cli/shared/api-client.ts +++ /dev/null @@ -1,183 +0,0 @@ -import chalk from "chalk"; - -import { type BaseConfig } from "./config.js"; - -/** - * Parse error response from the API, handling both JSON and text responses. - */ -export async function parseErrorResponse(response: Response): Promise { - let errorMessage = `HTTP ${response.status}: ${response.statusText}`; - - try { - const errorBody = await response.json(); - if ( - typeof errorBody === "object" && - errorBody !== null && - ("message" in errorBody || "error" in errorBody) - ) { - const typedErrorBody = errorBody as { message?: string; error?: string }; - errorMessage = - typedErrorBody.message || typedErrorBody.error || errorMessage; - } - } catch { - try { - const textBody = await response.text(); - if (textBody) { - errorMessage = textBody; - } - } catch { - // Keep default error message - } - } - - return errorMessage; -} - -/** - * Handle fetch errors with helpful messages for common issues. - */ -function handleFetchError(error: unknown, apiUrl: string): string { - const errorMessage = - error instanceof Error ? error.message : "Unknown error occurred"; - - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "ECONNREFUSED" - ) { - console.log( - chalk.yellow( - `\nCannot connect to ${apiUrl}. Make sure the API server is reachable.`, - ), - ); - } - - return errorMessage; -} - -/** - * Make an authenticated GET request to the Browserbase API. - */ -export async function apiGet( - config: BaseConfig, - endpoint: string, -): Promise { - try { - const url = `${config.apiUrl}${endpoint}`; - const response = await fetch(url, { - method: "GET", - headers: { - "x-bb-api-key": config.apiKey, - }, - }); - - if (!response.ok) { - const errorMessage = await parseErrorResponse(response); - console.error(chalk.red(`API error: ${errorMessage}`)); - return null; - } - - return (await response.json()) as T; - } catch (error) { - handleFetchError(error, config.apiUrl); - return null; - } -} - -/** - * Make an authenticated POST request to the Browserbase API. - */ -export async function apiPost( - config: BaseConfig, - endpoint: string, - body: unknown, -): Promise<{ success: true; data: T } | { success: false; error: string }> { - try { - const url = `${config.apiUrl}${endpoint}`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-bb-api-key": config.apiKey, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorMessage = await parseErrorResponse(response); - return { success: false, error: errorMessage }; - } - - const data = (await response.json()) as T; - return { success: true, data }; - } catch (error) { - const errorMessage = handleFetchError(error, config.apiUrl); - return { success: false, error: errorMessage }; - } -} - -export type BuildStatus = "PENDING" | "RUNNING" | "COMPLETED" | "FAILED"; - -export type InvocationStatus = "PENDING" | "RUNNING" | "COMPLETED" | "FAILED"; - -export function isTerminalBuildStatus(status: BuildStatus): boolean { - return status !== "PENDING" && status !== "RUNNING"; -} - -export function isTerminalInvocationStatus(status: InvocationStatus): boolean { - return status !== "PENDING" && status !== "RUNNING"; -} - -export interface PollOptions { - /** Polling interval in milliseconds */ - intervalMs?: number; - /** Maximum number of polling attempts */ - maxAttempts?: number; - /** Message to display while polling */ - waitingMessage?: string; - /** Message to display when max attempts reached */ - timeoutMessage?: string; -} - -/** - * Poll a status endpoint until a terminal condition is reached. - */ -export async function pollUntil( - fetchStatus: () => Promise, - isTerminal: (status: T) => boolean, - getDisplayStatus: (status: T) => string, - options?: PollOptions, -): Promise { - const intervalMs = options?.intervalMs ?? 1000; - const maxAttempts = options?.maxAttempts ?? 900; - const waitingMessage = options?.waitingMessage ?? "Waiting for completion..."; - const timeoutMessage = - options?.timeoutMessage ?? "Still running after maximum wait time."; - - console.log(chalk.cyan(`\n${waitingMessage}`)); - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const status = await fetchStatus(); - - if (!status) { - console.error(chalk.red("Failed to get status")); - return null; - } - - process.stdout.write( - `\r${chalk.gray(`Status: ${getDisplayStatus(status)}... (${attempt + 1}/${maxAttempts})`)}`, - ); - - if (isTerminal(status)) { - process.stdout.write("\r" + " ".repeat(70) + "\r"); - return status; - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - process.stdout.write("\r" + " ".repeat(70) + "\r"); - console.error(chalk.yellow(timeoutMessage)); - return null; -} diff --git a/src/cli/shared/config.ts b/src/cli/shared/config.ts deleted file mode 100644 index d2afda2..0000000 --- a/src/cli/shared/config.ts +++ /dev/null @@ -1,65 +0,0 @@ -import chalk from "chalk"; -import "dotenv/config"; - -/** - * Base configuration required for all Browserbase API calls. - */ -export interface BaseConfig { - apiKey: string; - apiUrl: string; -} - -const DEFAULT_API_URL = "https://api.browserbase.com"; - -/** - * Get the Browserbase API key from environment. - * Exits the process with an error message if not found. - */ -export function requireApiKey(): string { - const apiKey = process.env["BROWSERBASE_API_KEY"]; - if (!apiKey) { - console.error( - chalk.red( - "Error: BROWSERBASE_API_KEY not found in environment variables.", - ), - ); - console.log( - chalk.gray( - "Please set BROWSERBASE_API_KEY in your .env file or environment.", - ), - ); - process.exit(1); - } - return apiKey; -} - -/** - * Get the API URL from options, environment, or use the default. - */ -export function getApiUrl(override?: string): string { - return override || process.env["BROWSERBASE_API_BASE_URL"] || DEFAULT_API_URL; -} - -/** - * Warn if API key doesn't match expected format. - */ -export function validateApiKeyFormat(apiKey: string): void { - if (!apiKey.startsWith("bb_")) { - console.warn( - chalk.yellow( - "Warning: API key doesn't start with 'bb_'. Make sure you're using a valid Browserbase API key.", - ), - ); - } -} - -/** - * Load base configuration (API key and URL). - * Use this for commands that don't need project-specific config. - */ -export function loadBaseConfig(options?: { apiUrl?: string }): BaseConfig { - return { - apiKey: requireApiKey(), - apiUrl: getApiUrl(options?.apiUrl), - }; -} diff --git a/src/cli/shared/index.ts b/src/cli/shared/index.ts deleted file mode 100644 index 5ad1e4b..0000000 --- a/src/cli/shared/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { - type BaseConfig, - requireApiKey, - getApiUrl, - validateApiKeyFormat, - loadBaseConfig, -} from "./config.js"; - -export { - parseErrorResponse, - apiGet, - apiPost, - pollUntil, - type PollOptions, - type BuildStatus, - type InvocationStatus, - isTerminalBuildStatus, - isTerminalInvocationStatus, -} from "./api-client.js"; diff --git a/src/core/archive.ts b/src/core/archive.ts new file mode 100644 index 0000000..3dda64c --- /dev/null +++ b/src/core/archive.ts @@ -0,0 +1,178 @@ +import archiver from "archiver"; +import ignore, { type Ignore } from "ignore"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, +} from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { FunctionsCoreError } from "./errors.js"; + +export const MAX_FUNCTION_ARCHIVE_SIZE_BYTES = 50 * 1024 * 1024; + +const defaultIgnorePatterns = [ + "node_modules/", + ".git/", + ".env", + ".env.*", + "*.log", + ".DS_Store", + "dist/", + "build/", + "*.zip", + "*.tar", + "*.tar.gz", + ".vscode/", + ".idea/", + ".browserbase/", +]; + +export interface FunctionArchive { + buffer: Buffer; + entries: string[]; + size: number; +} + +export async function listFunctionArchiveEntries( + root: string, +): Promise { + const ignoreMatcher = await loadIgnoreMatcher(root); + return await listArchiveEntries(root, root, ignoreMatcher); +} + +export async function createFunctionArchive( + root: string, +): Promise { + const sourceEntries = await listFunctionArchiveEntries(root); + const { entries, generatedLockfilePath } = ensureArchiveLockfile( + root, + sourceEntries, + ); + + try { + const chunks: Buffer[] = []; + await new Promise((resolvePromise, reject) => { + const archive = archiver("tar", { + gzip: true, + gzipOptions: { level: 9 }, + }); + + archive.on("data", (chunk: Buffer) => chunks.push(chunk)); + archive.on("end", resolvePromise); + archive.on("error", reject); + archive.on("warning", (warning: Error & { code?: string }) => { + if (warning.code !== "ENOENT") { + reject(warning); + } + }); + + for (const entry of entries) { + const sourcePath = + entry === "package-lock.json" && generatedLockfilePath + ? generatedLockfilePath + : join(root, entry); + archive.file(sourcePath, { name: entry }); + } + archive.finalize().catch(reject); + }); + + const buffer = Buffer.concat(chunks); + validateFunctionArchiveSize(buffer.length); + return { buffer, entries, size: buffer.length }; + } finally { + if (generatedLockfilePath) { + rmSync(dirname(generatedLockfilePath), { recursive: true, force: true }); + } + } +} + +export function validateFunctionArchiveSize( + size: number, + maxSizeBytes: number = MAX_FUNCTION_ARCHIVE_SIZE_BYTES, +): void { + if (size <= maxSizeBytes) { + return; + } + throw new FunctionsCoreError( + `Functions archive is ${(size / 1024 / 1024).toFixed(2)} MB; the maximum is ${(maxSizeBytes / 1024 / 1024).toFixed(0)} MB. Add files to .gitignore to reduce its size.`, + { code: "archive_too_large" }, + ); +} + +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.error || result.status !== 0) { + rmSync(tempDir, { recursive: true, force: true }); + throw new FunctionsCoreError( + "Failed to generate package-lock.json for the Functions build archive.", + { cause: result.error, code: "package_install_failed" }, + ); + } + + return { + entries: [...entries, "package-lock.json"].sort(), + generatedLockfilePath: join(tempDir, "package-lock.json"), + }; +} + +async function loadIgnoreMatcher(root: string): Promise { + 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, +): 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 ((await stat(absolutePath)).isFile()) { + files.push(relativePath); + } + } + return files.sort(); +} diff --git a/src/core/core.test.ts b/src/core/core.test.ts new file mode 100644 index 0000000..dd29fd9 --- /dev/null +++ b/src/core/core.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, it } from "node:test"; + +import { FunctionsCoreError } from "./errors.js"; +import { createFunctionProject } from "./init.js"; +import { invokeFunction } from "./invoke.js"; +import { publishFunction } from "./publish.js"; +import { parseJsonArgument, resolveFunctionsApiConfig } from "./shared.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Functions core", () => { + it("throws typed errors instead of exiting for missing credentials", () => { + assert.throws( + () => resolveFunctionsApiConfig({ env: {} }), + (error: unknown) => + error instanceof FunctionsCoreError && error.code === "missing_api_key", + ); + }); + + it("parses JSON params without CLI output", () => { + assert.deepEqual(parseJsonArgument('{"answer":42}', "params"), { + answer: 42, + }); + assert.throws( + () => parseJsonArgument("{", "params"), + (error: unknown) => + error instanceof FunctionsCoreError && error.code === "invalid_json", + ); + }); + + it("creates a canonical scaffold with caller-provided CLI scripts", async () => { + const cwd = await createTempDir("init"); + const result = await createFunctionProject({ + cwd, + install: false, + packageManager: "npm", + projectName: "demo-function", + scripts: { + deploy: "browse functions publish index.ts", + dev: "browse functions dev index.ts", + }, + }); + + const packageJson = JSON.parse( + readFileSync(join(result.projectRoot, "package.json"), "utf8"), + ) as { + packageManager: string; + scripts: { deploy: string; dev: string }; + version: string; + }; + assert.match(packageJson.packageManager, /^npm@/); + assert.equal(packageJson.version, "1.0.0"); + assert.equal(packageJson.scripts.dev, "browse functions dev index.ts"); + assert.ok(existsSync(join(result.projectRoot, "index.ts"))); + }); + + it("publishes and polls through the injected transport", async () => { + const cwd = await createTempDir("publish"); + writeFileSync( + join(cwd, "package.json"), + JSON.stringify({ name: "fixture", version: "1.0.0" }), + ); + writeFileSync(join(cwd, "index.ts"), "export {};\n"); + const requests: Array<{ method: string; url: string }> = []; + let buildPolls = 0; + const fetchImplementation: typeof fetch = async (input, init) => { + const url = String(input); + requests.push({ method: init?.method ?? "GET", url }); + if (url.endsWith("/v1/functions/builds") && init?.method === "POST") { + return Response.json({ id: "build_123" }); + } + buildPolls += 1; + return Response.json({ + id: "build_123", + status: buildPolls === 1 ? "RUNNING" : "COMPLETED", + builtFunctions: [{ id: "fn_123", name: "fixture" }], + }); + }; + + const result = await publishFunction({ + apiKey: "test-key", + baseUrl: "https://functions.test", + cwd, + entrypoint: "index.ts", + fetch: fetchImplementation, + pollIntervalMs: 0, + }); + + assert.equal(result.dryRun, false); + assert.equal(result.dryRun ? "" : result.build.status, "COMPLETED"); + assert.deepEqual( + requests.map((request) => request.method), + ["POST", "GET", "GET"], + ); + }); + + it("invokes and returns the final typed result", async () => { + let request = 0; + const fetchImplementation: typeof fetch = async () => { + request += 1; + if (request === 1) { + return Response.json({ + functionId: "fn_123", + id: "inv_123", + status: "PENDING", + }); + } + return Response.json({ + functionId: "fn_123", + id: "inv_123", + results: { ok: true }, + status: "COMPLETED", + }); + }; + + const result = await invokeFunction({ + apiKey: "test-key", + baseUrl: "https://functions.test", + fetch: fetchImplementation, + functionId: "fn_123", + params: { hello: "world" }, + pollIntervalMs: 0, + }); + assert.equal(result.status, "COMPLETED"); + assert.deepEqual(result.results, { ok: true }); + }); +}); + +async function createTempDir(label: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `functions-core-${label}-`)); + tempDirs.push(dir); + return dir; +} diff --git a/src/core/dev.ts b/src/core/dev.ts new file mode 100644 index 0000000..b8ac5d8 --- /dev/null +++ b/src/core/dev.ts @@ -0,0 +1,744 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile } from "node:fs/promises"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { createRequire } from "node:module"; +import { join, resolve } from "node:path"; + +import { FunctionsCoreError } from "./errors.js"; +import { + formatErrorMessage, + functionsRequest, + resolveEntrypoint, + resolveFunctionsApiConfig, + type FunctionsApiConfig, + type ResolveFunctionsApiConfigOptions, +} from "./shared.js"; + +const DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS = 10_000; + +export type DevServerLogEvent = + | { level: "error"; message: string; source: "server" } + | { level: "error" | "info"; message: string; source: "runtime" } + | { level: "warn"; message: string; source: "session" }; + +export interface StartDevServerOptions + extends ResolveFunctionsApiConfigOptions { + cwd?: string; + entrypoint: string; + host?: string; + onLog?: (event: DevServerLogEvent) => void; + port?: number; + projectId?: string; + startupTimeoutMs?: number; + verbose?: boolean; +} + +export interface DevServerHandle { + close(): Promise; + runtimeConnected: boolean; + url: string; +} + +interface InvocationContext { + invocation: { id: string; region: "local" }; + 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; + + constructor(private readonly onLog?: (event: DevServerLogEvent) => void) {} + + setCleanupSessionCallback( + callback: (sessionId: string) => Promise, + ): void { + this.cleanupSessionCallback = callback; + } + + holdNextConnection( + response: ServerResponse, + corsHeaders: Record, + ): void { + 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(): boolean { + return this.runtimeConnected && this.nextConnection !== null; + } + + hasActiveInvocation(): boolean { + return this.invokeConnection !== null; + } + + async completeWithSuccess( + requestId: string, + payload: unknown, + ): Promise { + if (requestId !== this.currentRequestId || !this.invokeConnection) { + return false; + } + sendJson( + this.invokeConnection.response, + 200, + payload ?? {}, + this.invokeConnection.corsHeaders, + ); + await this.cleanupAndReset(); + return true; + } + + async completeWithError( + requestId: string, + payload: RuntimeError, + ): Promise { + if (requestId !== this.currentRequestId || !this.invokeConnection) { + return false; + } + sendJson( + this.invokeConnection.response, + 500, + { + error: { + message: payload.errorMessage, + stackTrace: payload.stackTrace, + type: payload.errorType, + }, + }, + this.invokeConnection.corsHeaders, + ); + await this.cleanupAndReset(); + 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:us-east-1:000000000000:function:${functionName}`, + }); + this.nextConnection.response.end( + JSON.stringify({ context, functionName, params }), + ); + this.nextConnection = null; + return true; + } + + private async cleanupAndReset(): Promise { + try { + if (this.cleanupSessionCallback && this.currentSessionId) { + await this.cleanupSessionCallback(this.currentSessionId); + } + } catch (error) { + this.onLog?.({ + level: "warn", + message: `Functions dev session cleanup failed: ${formatErrorMessage(error)}`, + source: "session", + }); + } finally { + this.currentRequestId = null; + this.currentSessionId = null; + this.invokeConnection = null; + } + } +} + +class BrowserSessionManager { + constructor( + private readonly config: FunctionsApiConfig, + private readonly projectId?: string, + ) {} + + async createSession( + sessionConfig: Record = {}, + ): Promise { + const body: Record = { ...sessionConfig }; + if (this.projectId !== undefined) { + body["projectId"] = this.projectId; + } + const response = await functionsRequest(this.config, "/v1/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const session = (await response.json()) as { + id?: string; + connectUrl?: string; + }; + if (!session.id || !session.connectUrl) { + throw new FunctionsCoreError( + "Browserbase session create completed without returning id and connectUrl.", + { code: "request_failed", responseBody: session }, + ); + } + return { connectUrl: session.connectUrl, id: session.id }; + } + + async closeSession(sessionId: string): Promise { + const body: Record = { status: "REQUEST_RELEASE" }; + if (this.projectId !== undefined) { + body["projectId"] = this.projectId; + } + await functionsRequest(this.config, `/v1/sessions/${sessionId}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } +} + +class ManifestStore { + private readonly manifests = new Map(); + private readonly manifestsPath: string; + + constructor(cwd: string) { + this.manifestsPath = join(cwd, ".browserbase", "functions", "manifests"); + } + + async load(): Promise { + this.manifests.clear(); + if (!existsSync(this.manifestsPath)) { + return; + } + for (const entry of await readdir(this.manifestsPath)) { + 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 child: ChildProcess | null = null; + + constructor( + private readonly cwd: string, + private readonly entrypoint: string, + private readonly runtimeApi: string, + private readonly verbose: boolean, + private readonly onLog?: (event: DevServerLogEvent) => void, + ) {} + + async start(): Promise { + 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: this.cwd, + env: { + ...process.env, + AWS_LAMBDA_RUNTIME_API: this.runtimeApi, + BB_FUNCTIONS_PHASE: "runtime", + NODE_ENV: "local", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + this.child = child; + child.stdout?.on("data", (chunk: Buffer) => { + const message = chunk.toString().trim(); + if (message) { + this.onLog?.({ + level: "info", + message: this.verbose ? `[runtime] ${message}` : message, + source: "runtime", + }); + } + }); + child.stderr?.on("data", (chunk: Buffer) => { + const message = chunk.toString().trim(); + if (message) { + this.onLog?.({ + level: "error", + message: this.verbose ? `[runtime:error] ${message}` : message, + source: "runtime", + }); + } + }); + child.once("exit", () => { + if (this.child === child) { + this.child = null; + } + }); + + try { + await waitForChildSpawn(child); + } catch (error) { + this.child = null; + throw new FunctionsCoreError( + `Failed to start Functions runtime: ${formatErrorMessage(error)}`, + { cause: error, code: "runtime_start_failed" }, + ); + } + } + + async stop(): Promise { + const child = this.child; + if (!child || child.exitCode !== null || child.signalCode !== null) { + this.child = 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.child = null; + } +} + +export async function startDevServer( + options: StartDevServerOptions, +): Promise { + const cwd = resolve(options.cwd ?? process.cwd()); + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? 14_113; + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new FunctionsCoreError( + "Port must be an integer between 1 and 65535.", + { + code: "invalid_port", + }, + ); + } + const entrypoint = await resolveEntrypoint(options.entrypoint, cwd); + const config = resolveFunctionsApiConfig(options); + const bridge = new InvocationBridge(options.onLog); + const sessionManager = new BrowserSessionManager(config, options.projectId); + const manifestStore = new ManifestStore(cwd); + bridge.setCleanupSessionCallback(async (sessionId) => { + await sessionManager.closeSession(sessionId); + }); + + await mkdir(join(cwd, ".browserbase", "functions", "manifests"), { + recursive: true, + }); + const server = await startServer( + host, + port, + bridge, + manifestStore, + sessionManager, + options.onLog, + ); + const runtime = new RuntimeProcess( + cwd, + entrypoint, + `${host}:${port}`, + options.verbose ?? false, + options.onLog, + ); + try { + await runtime.start(); + const runtimeConnected = await waitForRuntime( + bridge, + manifestStore, + options.startupTimeoutMs ?? DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS, + ); + let closed = false; + return { + async close() { + if (closed) { + return; + } + closed = true; + await runtime.stop(); + await closeServer(server); + }, + runtimeConnected, + url: `http://${host}:${port}`, + }; + } catch (error) { + await runtime.stop(); + await closeServer(server); + throw error; + } +} + +async function startServer( + host: string, + port: number, + bridge: InvocationBridge, + manifestStore: ManifestStore, + sessionManager: BrowserSessionManager, + onLog?: (event: DevServerLogEvent) => void, +): Promise { + const server = createServer((request, response) => { + routeRequest( + request, + response, + bridge, + manifestStore, + sessionManager, + ).catch((error) => { + const message = formatErrorMessage(error); + onLog?.({ + level: "error", + message: `Functions dev request failed: ${message}`, + source: "server", + }); + if (!response.headersSent && !response.writableEnded) { + sendJson(response, 500, { error: message }, baseCorsHeaders()); + } else if (!response.writableEnded) { + response.end(); + } + }); + }); + await new Promise((resolvePromise, reject) => { + server.listen(port, host, resolvePromise); + server.on("error", reject); + }); + return server; +} + +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") { + response.writeHead(204, corsHeaders); + response.end(); + 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: unknown; + try { + body = await readJsonBody(request); + } catch (error) { + sendJson( + response, + 400, + { error: formatErrorMessage(error) }, + 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, + { + invocation: { id: randomUUID(), region: "local" }, + 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: unknown; + 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 body: unknown; + try { + body = await readJsonBody(request); + } catch (error) { + body = { + errorMessage: `Invalid runtime error payload: ${formatErrorMessage(error)}`, + errorType: "RuntimeResponseError", + stackTrace: [], + }; + } + const payload = body as Partial; + 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); +} + +interface RuntimeError { + errorMessage: string; + errorType: string; + stackTrace: string[]; +} + +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(); +} + +async function waitForChildSpawn(child: ChildProcess): 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 closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolvePromise, reject) => { + server.close((error) => (error ? reject(error) : resolvePromise())); + }); +} + +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"); + return text ? (JSON.parse(text) as unknown) : {}; +} + +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 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) || !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); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) + ); + } catch { + return false; + } +} diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..51b9294 --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,46 @@ +export type FunctionsCoreErrorCode = + | "archive_too_large" + | "build_failed" + | "build_missing_id" + | "directory_exists" + | "http_error" + | "invalid_entrypoint" + | "invalid_json" + | "invalid_package_manager" + | "invalid_port" + | "invalid_project_name" + | "invocation_failed" + | "missing_api_key" + | "missing_function_id" + | "package_install_failed" + | "request_failed" + | "runtime_start_failed" + | "timeout"; + +export interface FunctionsCoreErrorOptions { + cause?: unknown; + code: FunctionsCoreErrorCode; + httpStatus?: number; + responseBody?: unknown; +} + +export class FunctionsCoreError extends Error { + readonly code: FunctionsCoreErrorCode; + readonly httpStatus?: number; + readonly responseBody?: unknown; + + constructor(message: string, options: FunctionsCoreErrorOptions) { + super( + message, + options.cause === undefined ? undefined : { cause: options.cause }, + ); + this.name = "FunctionsCoreError"; + this.code = options.code; + if (options.httpStatus !== undefined) { + this.httpStatus = options.httpStatus; + } + if (options.responseBody !== undefined) { + this.responseBody = options.responseBody; + } + } +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..4bb6f89 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,58 @@ +export { + createFunctionArchive, + listFunctionArchiveEntries, + MAX_FUNCTION_ARCHIVE_SIZE_BYTES, + validateFunctionArchiveSize, + type FunctionArchive, +} from "./archive.js"; +export { + startDevServer, + type DevServerHandle, + type DevServerLogEvent, + type StartDevServerOptions, +} from "./dev.js"; +export { + FunctionsCoreError, + type FunctionsCoreErrorCode, + type FunctionsCoreErrorOptions, +} from "./errors.js"; +export { + createFunctionProject, + type CreateFunctionProjectOptions, + type CreateFunctionProjectResult, + type FunctionPackageManager, + type InitProgressEvent, +} from "./init.js"; +export { + getInvocationStatus, + invokeFunction, + type InvocationResponse, + type InvocationStatus, + type InvokeFunctionOptions, +} from "./invoke.js"; +export { + getBuildStatus, + publishFunction, + type BuildStatus, + type BuildStatusResponse, + type BuiltFunction, + type FunctionCreatedVersion, + type PublishCompletedResult, + type PublishDryRunResult, + type PublishFunctionOptions, + type PublishFunctionResult, +} from "./publish.js"; +export { + DEFAULT_FUNCTIONS_API_URL, + formatErrorMessage, + functionsGet, + functionsPost, + functionsRequest, + parseJsonArgument, + pollUntil, + resolveEntrypoint, + resolveFunctionsApiConfig, + type FunctionsApiConfig, + type PollOptions, + type ResolveFunctionsApiConfigOptions, +} from "./shared.js"; diff --git a/src/core/init.ts b/src/core/init.ts new file mode 100644 index 0000000..f9889d0 --- /dev/null +++ b/src/core/init.ts @@ -0,0 +1,208 @@ +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 { FunctionsCoreError } from "./errors.js"; + +export type FunctionPackageManager = "npm" | "pnpm"; + +export type InitProgressEvent = + | { type: "directory-created"; path: string } + | { type: "file-created"; path: string } + | { type: "dependencies-installed"; packageManager: FunctionPackageManager } + | { type: "git-initialized"; path: string }; + +export interface CreateFunctionProjectOptions { + cwd?: string; + install?: boolean; + onOutput?: (stream: "stdout" | "stderr", text: string) => void; + onProgress?: (event: InitProgressEvent) => void; + packageManager?: FunctionPackageManager; + packageSpecifier?: string; + projectName: string; + scripts?: { + deploy: string; + dev: string; + }; +} + +export interface CreateFunctionProjectResult { + packageManager: FunctionPackageManager; + packageManagerVersion: string; + projectRoot: string; +} + +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://example.com"); + const title = await page.title(); + + return { title }; +}); +`; + +const tsconfigTemplate = `{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + } +} +`; + +export async function createFunctionProject( + options: CreateFunctionProjectOptions, +): Promise { + if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(options.projectName)) { + throw new FunctionsCoreError( + `Invalid project name "${options.projectName}". Use a leading letter, then letters, numbers, hyphens, or underscores.`, + { code: "invalid_project_name" }, + ); + } + + const packageManager = options.packageManager ?? "pnpm"; + if (packageManager !== "npm" && packageManager !== "pnpm") { + throw new FunctionsCoreError( + `Unsupported package manager: ${packageManager}`, + { + code: "invalid_package_manager", + }, + ); + } + const packageManagerVersion = requireCommandVersion(packageManager); + const cwd = resolve(options.cwd ?? process.cwd()); + const projectRoot = resolve(cwd, options.projectName); + if (existsSync(projectRoot)) { + throw new FunctionsCoreError(`Directory already exists: ${projectRoot}`, { + code: "directory_exists", + }); + } + + await mkdir(projectRoot, { recursive: true }); + options.onProgress?.({ path: projectRoot, type: "directory-created" }); + + const scripts = options.scripts ?? { + deploy: "bb publish index.ts", + dev: "bb dev index.ts", + }; + const packageJson = { + name: options.projectName, + version: "1.0.0", + private: true, + packageManager: `${packageManager}@${packageManagerVersion}`, + type: "module", + scripts, + }; + + const files: Record = { + ".env": envTemplate, + ".gitignore": gitignoreTemplate, + "index.ts": starterFunctionTemplate, + "package.json": `${JSON.stringify(packageJson, null, 2)}\n`, + "tsconfig.json": tsconfigTemplate, + }; + for (const [name, contents] of Object.entries(files)) { + const path = join(projectRoot, name); + await writeFile(path, contents); + options.onProgress?.({ path, type: "file-created" }); + } + + if (options.install ?? true) { + const packageSpecifier = + options.packageSpecifier ?? "@browserbasehq/sdk-functions"; + const install = packageManager === "pnpm" ? ["add"] : ["install"]; + const installDev = + packageManager === "pnpm" ? ["add", "-D"] : ["install", "--save-dev"]; + + runPackageManager( + packageManager, + [...install, packageSpecifier, "playwright-core", "zod"], + projectRoot, + options.onOutput, + ); + runPackageManager( + packageManager, + [...installDev, "typescript", "@types/node"], + projectRoot, + options.onOutput, + ); + options.onProgress?.({ packageManager, type: "dependencies-installed" }); + } + + const git = spawnSync("git", ["init"], { + cwd: projectRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (!git.error && git.status === 0) { + options.onProgress?.({ path: projectRoot, type: "git-initialized" }); + } + + return { packageManager, packageManagerVersion, projectRoot }; +} + +function requireCommandVersion(command: FunctionPackageManager): string { + const result = spawnSync(command, ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.error || result.status !== 0) { + throw new FunctionsCoreError( + `${command} is required but was not found on PATH.`, + { + cause: result.error, + code: "invalid_package_manager", + }, + ); + } + return result.stdout.trim(); +} + +function runPackageManager( + packageManager: FunctionPackageManager, + args: string[], + cwd: string, + onOutput?: (stream: "stdout" | "stderr", text: string) => void, +): void { + const result = spawnSync(packageManager, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.stdout) { + onOutput?.("stdout", result.stdout); + } + if (result.stderr) { + onOutput?.("stderr", result.stderr); + } + if (result.error || result.status !== 0) { + throw new FunctionsCoreError( + `Failed to install dependencies with ${packageManager}.`, + { cause: result.error, code: "package_install_failed" }, + ); + } +} diff --git a/src/core/invoke.ts b/src/core/invoke.ts new file mode 100644 index 0000000..8ed1d23 --- /dev/null +++ b/src/core/invoke.ts @@ -0,0 +1,94 @@ +import { FunctionsCoreError } from "./errors.js"; +import { + functionsGet, + functionsPost, + pollUntil, + resolveFunctionsApiConfig, + type FunctionsApiConfig, + type ResolveFunctionsApiConfigOptions, +} from "./shared.js"; + +export type InvocationStatus = "PENDING" | "RUNNING" | "COMPLETED" | "FAILED"; + +export interface InvocationResponse { + id: string; + functionId: string; + status: InvocationStatus; + params?: Record; + results?: unknown; + sessionId?: string; + createdAt?: string; + updatedAt?: string; + startedAt?: string; + endedAt?: string; + expiresAt?: string; +} + +export interface InvokeFunctionOptions + extends ResolveFunctionsApiConfigOptions { + checkStatus?: string; + functionId?: string; + noWait?: boolean; + params?: unknown; + pollIntervalMs?: number; + pollMaxAttempts?: number; + onInvocationStatus?: ( + invocation: InvocationResponse, + attempt: number, + ) => void; +} + +export async function invokeFunction( + options: InvokeFunctionOptions, +): Promise { + const config = resolveFunctionsApiConfig(options); + + if (options.checkStatus) { + return await getInvocationStatus(config, options.checkStatus); + } + if (!options.functionId) { + throw new FunctionsCoreError( + "functionId is required unless checkStatus is used.", + { code: "missing_function_id" }, + ); + } + + const invocation = await functionsPost( + config, + `/v1/functions/${options.functionId}/invoke`, + { params: options.params ?? {} }, + ); + if (options.noWait) { + return invocation; + } + + const finalStatus = await pollUntil( + () => getInvocationStatus(config, invocation.id), + { + done: (result) => !["PENDING", "RUNNING"].includes(result.status), + intervalMs: options.pollIntervalMs ?? 1_000, + maxAttempts: options.pollMaxAttempts ?? 900, + ...(options.onInvocationStatus + ? { onPoll: options.onInvocationStatus } + : {}), + }, + ); + + if (finalStatus.status === "FAILED") { + throw new FunctionsCoreError("Function invocation failed.", { + code: "invocation_failed", + responseBody: finalStatus, + }); + } + return finalStatus; +} + +export async function getInvocationStatus( + config: FunctionsApiConfig, + invocationId: string, +): Promise { + return await functionsGet( + config, + `/v1/functions/invocations/${invocationId}`, + ); +} diff --git a/src/core/publish.ts b/src/core/publish.ts new file mode 100644 index 0000000..0b700f6 --- /dev/null +++ b/src/core/publish.ts @@ -0,0 +1,158 @@ +import { relative, resolve } from "node:path"; + +import { + createFunctionArchive, + listFunctionArchiveEntries, +} from "./archive.js"; +import { FunctionsCoreError } from "./errors.js"; +import { + functionsGet, + functionsRequest, + pollUntil, + resolveEntrypoint, + resolveFunctionsApiConfig, + type FunctionsApiConfig, + type ResolveFunctionsApiConfigOptions, +} from "./shared.js"; + +export type BuildStatus = "PENDING" | "RUNNING" | "COMPLETED" | "FAILED"; + +export interface FunctionCreatedVersion { + id: string; + functionId: string; + functionBuildId: string; + sessionCreateParams?: Record; + createdAt?: string; + updatedAt?: string; +} + +export interface BuiltFunction { + id: string; + name: string; + createdVersion?: FunctionCreatedVersion; + createdAt?: string; + updatedAt?: string; +} + +export interface BuildStatusResponse { + id: string; + status: BuildStatus; + request?: { entrypoint?: string; projectId?: string }; + builtFunctions?: BuiltFunction[]; + createdAt?: string; + updatedAt?: string; + startedAt?: string; + endedAt?: string; + expiresAt?: string; +} + +export interface PublishFunctionOptions + extends ResolveFunctionsApiConfigOptions { + cwd?: string; + dryRun?: boolean; + entrypoint: string; + projectId?: string; + pollIntervalMs?: number; + pollMaxAttempts?: number; + onBuildStatus?: (build: BuildStatusResponse, attempt: number) => void; +} + +export interface PublishDryRunResult { + baseUrl: string; + dryRun: true; + entrypoint: string; + files: string[]; + projectId?: string; +} + +export interface PublishCompletedResult { + build: BuildStatusResponse; + dryRun: false; +} + +export type PublishFunctionResult = + | PublishDryRunResult + | PublishCompletedResult; + +export async function publishFunction( + options: PublishFunctionOptions, +): Promise { + const cwd = resolve(options.cwd ?? process.cwd()); + const entrypoint = await resolveEntrypoint(options.entrypoint, cwd); + const entrypointPath = relative(cwd, entrypoint); + const config = resolveFunctionsApiConfig(options); + + if (options.dryRun) { + const result: PublishDryRunResult = { + baseUrl: config.baseUrl, + dryRun: true, + entrypoint: entrypointPath, + files: await listFunctionArchiveEntries(cwd), + }; + if (options.projectId !== undefined) { + result.projectId = options.projectId; + } + return result; + } + + const archive = await createFunctionArchive(cwd); + const metadata: { entrypoint: string; projectId?: string } = { + entrypoint: entrypointPath, + }; + if (options.projectId !== undefined) { + metadata.projectId = options.projectId; + } + + const formData = new FormData(); + formData.append("metadata", JSON.stringify(metadata)); + formData.append( + "archive", + new Blob([archive.buffer], { type: "application/gzip" }), + "archive.tar.gz", + ); + + const uploadResponse = await functionsRequest( + config, + "/v1/functions/builds", + { + method: "POST", + body: formData, + }, + ); + const uploaded = (await uploadResponse.json()) as { id?: string }; + if (!uploaded.id) { + throw new FunctionsCoreError( + "Build upload completed without returning a build ID.", + { code: "build_missing_id" }, + ); + } + + const build = await pollUntil( + () => getBuildStatus(config, uploaded.id as string), + { + done: (result) => !["PENDING", "RUNNING"].includes(result.status), + intervalMs: options.pollIntervalMs ?? 2_000, + maxAttempts: options.pollMaxAttempts ?? 100, + ...(options.onBuildStatus ? { onPoll: options.onBuildStatus } : {}), + }, + ); + + if (build.status === "FAILED") { + throw new FunctionsCoreError("Function build failed during processing.", { + code: "build_failed", + responseBody: build, + }); + } + + return { build, dryRun: false }; +} + +export async function getBuildStatus( + config: FunctionsApiConfig, + buildId: string, +): Promise { + return await functionsGet( + config, + `/v1/functions/builds/${buildId}`, + ); +} diff --git a/src/core/shared.ts b/src/core/shared.ts new file mode 100644 index 0000000..8b77b34 --- /dev/null +++ b/src/core/shared.ts @@ -0,0 +1,220 @@ +import { stat } from "node:fs/promises"; +import { extname, resolve } from "node:path"; + +import { FunctionsCoreError } from "./errors.js"; + +export const DEFAULT_FUNCTIONS_API_URL = "https://api.browserbase.com"; + +export interface FunctionsApiConfig { + apiKey: string; + baseUrl: string; + fetch?: typeof globalThis.fetch; + onResponse?: (response: Response) => void; +} + +export interface ResolveFunctionsApiConfigOptions { + apiKey?: string; + baseUrl?: string; + env?: NodeJS.ProcessEnv; + fetch?: typeof globalThis.fetch; + onResponse?: (response: Response) => void; +} + +export interface PollOptions { + done: (value: T) => boolean; + intervalMs?: number; + maxAttempts?: number; + onPoll?: (value: T, attempt: number) => void; +} + +export function resolveFunctionsApiConfig( + options: ResolveFunctionsApiConfigOptions = {}, +): FunctionsApiConfig { + const env = options.env ?? process.env; + const apiKey = options.apiKey ?? env["BROWSERBASE_API_KEY"]; + if (!apiKey) { + throw new FunctionsCoreError( + "Missing Browserbase API key. Set BROWSERBASE_API_KEY or pass apiKey.", + { code: "missing_api_key" }, + ); + } + + const config: FunctionsApiConfig = { + apiKey, + baseUrl: + options.baseUrl ?? + env["BROWSERBASE_BASE_URL"] ?? + env["BROWSERBASE_API_BASE_URL"] ?? + DEFAULT_FUNCTIONS_API_URL, + }; + if (options.fetch !== undefined) { + config.fetch = options.fetch; + } + if (options.onResponse !== undefined) { + config.onResponse = options.onResponse; + } + return config; +} + +export async function functionsRequest( + config: FunctionsApiConfig, + path: string, + init: RequestInit = {}, +): Promise { + const fetchImplementation = config.fetch ?? globalThis.fetch; + let response: Response; + try { + response = await fetchImplementation(new URL(path, config.baseUrl), { + ...init, + headers: { + "x-bb-api-key": config.apiKey, + ...(init.headers ?? {}), + }, + }); + } catch (error) { + throw new FunctionsCoreError(formatErrorMessage(error), { + cause: error, + code: "request_failed", + }); + } + + config.onResponse?.(response); + if (!response.ok) { + const responseBody = await readErrorBody(response); + throw new FunctionsCoreError(formatResponseError(response, responseBody), { + code: "http_error", + httpStatus: response.status, + responseBody, + }); + } + 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" }, + 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 = 1; attempt <= maxAttempts; attempt += 1) { + const value = await loader(); + options.onPoll?.(value, attempt); + if (options.done(value)) { + return value; + } + await new Promise((resolvePromise) => + setTimeout(resolvePromise, intervalMs), + ); + } + + throw new FunctionsCoreError( + "Timed out while waiting for the Browserbase Functions operation to complete.", + { code: "timeout" }, + ); +} + +export async function resolveEntrypoint( + entrypoint: string, + cwd: string = process.cwd(), +): Promise { + const absolutePath = resolve(cwd, entrypoint); + let entrypointStat; + try { + entrypointStat = await stat(absolutePath); + } catch { + throw new FunctionsCoreError(`Entrypoint file not found: ${absolutePath}`, { + code: "invalid_entrypoint", + }); + } + + if (!entrypointStat.isFile()) { + throw new FunctionsCoreError(`Entrypoint must be a file: ${absolutePath}`, { + code: "invalid_entrypoint", + }); + } + + const extension = extname(absolutePath).toLowerCase(); + if ( + ![".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts"].includes(extension) + ) { + throw new FunctionsCoreError( + `Unsupported entrypoint extension: ${extension}`, + { + code: "invalid_entrypoint", + }, + ); + } + + return absolutePath; +} + +export function parseJsonArgument( + rawValue: string | undefined, + label: string, +): unknown { + if (!rawValue) { + return {}; + } + try { + return JSON.parse(rawValue); + } catch (error) { + throw new FunctionsCoreError( + `Invalid JSON for ${label}: ${formatErrorMessage(error)}`, + { cause: error, code: "invalid_json" }, + ); + } +} + +export function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function readErrorBody(response: Response): Promise { + const text = await response.text(); + if (!text) { + return undefined; + } + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function formatResponseError(response: Response, body: unknown): string { + if (body && typeof body === "object") { + const record = body as { error?: unknown; message?: unknown }; + if (typeof record.message === "string") { + return record.message; + } + if (typeof record.error === "string") { + return record.error; + } + } + if (typeof body === "string") { + return body; + } + return `HTTP ${response.status}: ${response.statusText}`; +} diff --git a/tests/integration/build-flow.test.ts b/tests/integration/build-flow.test.ts index a4f4299..740edb2 100644 --- a/tests/integration/build-flow.test.ts +++ b/tests/integration/build-flow.test.ts @@ -119,6 +119,49 @@ console.log("CJS_OK"); assert.ok(String(output).includes("CJS_OK"), "CJS require should work"); }); + it("core subpath supports ESM import", () => { + const dir = createTempDir("core-esm"); + setupTempProject(dir, { + type: "module", + files: { + "index.mjs": ` +import { FunctionsCoreError, publishFunction } from "@browserbasehq/sdk-functions/core"; +if (typeof publishFunction !== "function" || typeof FunctionsCoreError !== "function") { + process.exit(1); +} +console.log("CORE_ESM_OK"); +`, + }, + }); + + const output = execSync("node index.mjs", { + cwd: dir, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + assert.ok(String(output).includes("CORE_ESM_OK")); + }); + + it("core subpath supports CJS require", () => { + const dir = createTempDir("core-cjs"); + setupTempProject(dir, { + files: { + "index.cjs": ` +const core = require("@browserbasehq/sdk-functions/core"); +if (typeof core.publishFunction !== "function") process.exit(1); +console.log("CORE_CJS_OK"); +`, + }, + }); + + const output = execSync("node index.cjs", { + cwd: dir, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + assert.ok(String(output).includes("CORE_CJS_OK")); + }); + it("TypeScript declarations are present", () => { const dir = createTempDir("dts"); setupTempProject(dir, { @@ -133,19 +176,79 @@ console.log("CJS_OK"); "@browserbasehq", "sdk-functions", "dist", + "types", "index.d.ts", ); - const dctsPath = join( + + const coreDtsPath = join( dir, "node_modules", "@browserbasehq", "sdk-functions", "dist", - "index.d.cts", + "types", + "core", + "index.d.ts", ); assert.ok(existsSync(dtsPath), `.d.ts should exist at ${dtsPath}`); - assert.ok(existsSync(dctsPath), `.d.cts should exist at ${dctsPath}`); + assert.ok( + existsSync(coreDtsPath), + `core .d.ts should exist at ${coreDtsPath}`, + ); + }); + + it("core declarations compile for a TypeScript consumer", () => { + const dir = createTempDir("core-typescript"); + setupTempProject(dir, { + type: "module", + extraDeps: ["typescript", "@types/node"], + files: { + "index.ts": ` +import { + FunctionsCoreError, + publishFunction, + type PublishFunctionResult, +} from "@browserbasehq/sdk-functions/core"; + +async function publish(): Promise { + try { + return await publishFunction({ apiKey: "test", dryRun: true, entrypoint: "index.ts" }); + } catch (error) { + if (error instanceof FunctionsCoreError) console.log(error.code); + throw error; + } +} + +void publish; +`, + "tsconfig.json": JSON.stringify({ + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + strict: true, + target: "ES2022", + types: ["node"], + }, + include: ["index.ts"], + }), + }, + }); + + try { + execSync("npx tsc --project tsconfig.json", { + cwd: dir, + stdio: "pipe", + }); + } catch (error) { + const output = error as { stderr?: Buffer; stdout?: Buffer }; + assert.fail( + [output.stdout?.toString(), output.stderr?.toString()] + .filter(Boolean) + .join("\n"), + ); + } }); it("bb CLI binary works", () => { diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..da49991 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "./dist/types", + "rootDir": "./src", + "sourceMap": false + }, + "include": ["src/index.ts", "src/core/**/*.ts"], + "exclude": ["src/**/*.test.ts", "node_modules", "dist", "tests"] +} diff --git a/tsup.config.ts b/tsup.config.ts index 7e0019a..9db8e39 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -8,10 +8,13 @@ const packageJson = JSON.parse( ); export default defineConfig([ - // Main SDK build + // Public library builds { - entry: ["src/index.ts"], - dts: true, // emit .d.ts + entry: { + index: "src/index.ts", + core: "src/core/index.ts", + }, + dts: false, sourcemap: true, clean: true, format: ["esm", "cjs"], // dual package @@ -42,6 +45,5 @@ export default defineConfig([ minifyIdentifiers: true, minifySyntax: true, minifyWhitespace: true, - onSuccess: "cp -r src/cli/init/templates dist/", }, ]);