browse
Version:
Unified Browserbase CLI for browser automation and cloud APIs.
521 lines (520 loc) • 18.7 kB
JavaScript
import { createRequire } from "node:module";
import { spawn } from "node:child_process";
import { createServer, } from "node:http";
import { mkdir, readdir, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { fail } from "../errors.js";
import { functionsRequest, resolveEntrypoint, resolveFunctionsApiConfig, } from "./shared.js";
const DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS = 10_000;
class InvocationBridge {
cleanupSessionCallback = null;
currentRequestId = null;
currentSessionId = null;
invokeConnection = null;
nextConnection = null;
runtimeConnected = false;
setCleanupSessionCallback(callback) {
this.cleanupSessionCallback = callback;
}
holdNextConnection(response, corsHeaders) {
this.runtimeConnected = true;
if (this.nextConnection) {
this.nextConnection.response.writeHead(503, {
...this.nextConnection.corsHeaders,
"content-type": "application/json",
});
this.nextConnection.response.end(JSON.stringify({ error: "Another runtime process connected." }));
}
this.nextConnection = { corsHeaders, response };
}
isRuntimeConnected() {
return this.runtimeConnected && this.nextConnection !== null;
}
hasActiveInvocation() {
return this.invokeConnection !== null;
}
async completeWithSuccess(requestId, payload) {
if (requestId !== this.currentRequestId || !this.invokeConnection) {
return false;
}
sendJson(this.invokeConnection.response, 200, payload ?? {}, this.invokeConnection.corsHeaders);
try {
await this.cleanupSession();
}
catch (error) {
this.reportCleanupError(error);
}
finally {
this.reset();
}
return true;
}
async completeWithError(requestId, payload) {
if (requestId !== this.currentRequestId || !this.invokeConnection) {
return false;
}
sendJson(this.invokeConnection.response, 500, { error: payload }, this.invokeConnection.corsHeaders);
try {
await this.cleanupSession();
}
catch (error) {
this.reportCleanupError(error);
}
finally {
this.reset();
}
return true;
}
triggerInvocation(functionName, params, context, corsHeaders, response) {
if (!this.nextConnection || this.invokeConnection) {
return false;
}
const requestId = randomUUID();
this.currentRequestId = requestId;
this.currentSessionId = context.session.id;
this.invokeConnection = { corsHeaders, response };
this.nextConnection.response.writeHead(200, {
...this.nextConnection.corsHeaders,
"content-type": "application/json",
"Lambda-Runtime-Aws-Request-Id": requestId,
"Lambda-Runtime-Deadline-Ms": String(Date.now() + 300_000),
"Lambda-Runtime-Invoked-Function-Arn": `arn:aws:lambda:local:function:${functionName}`,
});
this.nextConnection.response.end(JSON.stringify({
context,
functionName,
params,
}));
this.nextConnection = null;
return true;
}
async cleanupSession() {
if (this.cleanupSessionCallback && this.currentSessionId) {
await this.cleanupSessionCallback(this.currentSessionId);
}
}
reportCleanupError(error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`Functions dev session cleanup failed: ${message}\n`);
}
reset() {
this.currentRequestId = null;
this.currentSessionId = null;
this.invokeConnection = null;
}
}
class BrowserSessionManager {
config;
constructor(config) {
this.config = config;
}
async createSession(sessionConfig = {}) {
const response = await functionsRequest(this.config, "/v1/sessions", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(sessionConfig),
});
const session = (await response.json());
if (!session.id || !session.connectUrl) {
fail("Browserbase session create completed without returning id and connectUrl.");
}
return {
connectUrl: session.connectUrl,
id: session.id,
};
}
async closeSession(sessionId) {
await functionsRequest(this.config, `/v1/sessions/${sessionId}`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ status: "REQUEST_RELEASE" }),
});
}
}
class ManifestStore {
manifestsPath = join(process.cwd(), ".browserbase", "functions", "manifests");
manifests = new Map();
async load() {
this.manifests.clear();
if (!existsSync(this.manifestsPath)) {
return;
}
const entries = await readdir(this.manifestsPath);
for (const entry of entries) {
if (!entry.endsWith(".json")) {
continue;
}
const manifest = JSON.parse(await readFile(join(this.manifestsPath, entry), "utf8"));
this.manifests.set(manifest.name, manifest);
}
}
get(name) {
return this.manifests.get(name);
}
}
class RuntimeProcess {
entrypoint;
runtimeApi;
verbose;
process = null;
constructor(entrypoint, runtimeApi, verbose) {
this.entrypoint = entrypoint;
this.runtimeApi = runtimeApi;
this.verbose = verbose;
}
async start() {
const require = createRequire(import.meta.url);
const tsxCli = require.resolve("tsx/cli");
const nodeExecutable = "bun" in process.versions ? "node" : process.execPath;
const child = spawn(nodeExecutable, [tsxCli, "watch", "--clear-screen=false", this.entrypoint], {
cwd: process.cwd(),
env: {
...process.env,
AWS_LAMBDA_RUNTIME_API: this.runtimeApi,
BB_FUNCTIONS_PHASE: "runtime",
NODE_ENV: "local",
},
stdio: ["ignore", "pipe", "pipe"],
});
this.process = child;
child.stdout?.on("data", (chunk) => {
const text = chunk.toString().trim();
if (text) {
process.stderr.write(`${this.verbose ? "[runtime] " : ""}${text}\n`);
}
});
child.stderr?.on("data", (chunk) => {
const text = chunk.toString().trim();
if (text) {
process.stderr.write(`${this.verbose ? "[runtime:error] " : ""}${text}\n`);
}
});
child.once("exit", () => {
if (this.process === child) {
this.process = null;
}
});
try {
await waitForChildSpawn(child);
}
catch (error) {
this.process = null;
fail(`Failed to start functions runtime: ${formatErrorMessage(error)}`);
}
}
async stop() {
const child = this.process;
if (!child) {
return;
}
if (child.exitCode !== null || child.signalCode !== null) {
this.process = null;
return;
}
await new Promise((resolvePromise) => {
const forceKillTimer = setTimeout(() => {
child.kill("SIGKILL");
}, 5_000);
const finish = () => {
clearTimeout(forceKillTimer);
resolvePromise();
};
child.once("exit", finish);
if (!child.kill("SIGTERM")) {
child.off("exit", finish);
finish();
}
});
this.process = null;
}
}
export async function startFunctionsDevServer(options) {
const entrypoint = await resolveEntrypoint(options.entrypoint);
if (!Number.isInteger(options.port) ||
options.port < 1 ||
options.port > 65_535) {
fail("Port must be an integer between 1 and 65535.");
}
const config = resolveFunctionsApiConfig(options);
const runtimeApi = `${options.host}:${options.port}`;
const bridge = new InvocationBridge();
const sessionManager = new BrowserSessionManager(config);
const manifestStore = new ManifestStore();
bridge.setCleanupSessionCallback(async (sessionId) => {
await sessionManager.closeSession(sessionId);
});
await mkdir(join(process.cwd(), ".browserbase", "functions", "manifests"), {
recursive: true,
});
const server = await startServer(options.host, options.port, bridge, manifestStore, sessionManager);
const runtime = new RuntimeProcess(entrypoint, runtimeApi, options.verbose);
await runtime.start();
const runtimeConnected = await waitForRuntime(bridge, manifestStore, getRuntimeStartupTimeoutMs());
const output = {
ok: runtimeConnected,
runtimeConnected,
url: `http://${options.host}:${options.port}`,
};
if (!runtimeConnected) {
output.warning = [
"Functions runtime has not connected yet.",
"Check the runtime logs, then retry once the entrypoint is healthy.",
].join(" ");
}
console.log(JSON.stringify(output, null, 2));
const shutdown = async () => {
await runtime.stop();
await new Promise((resolvePromise) => server.close(() => resolvePromise()));
};
process.on("SIGINT", async () => {
await shutdown();
process.exit(0);
});
process.on("SIGTERM", async () => {
await shutdown();
process.exit(0);
});
}
async function startServer(host, port, bridge, manifestStore, sessionManager) {
const server = createServer((request, response) => {
routeRequest(request, response, bridge, manifestStore, sessionManager).catch((error) => {
handleRouteError(response, error);
});
});
await new Promise((resolvePromise, reject) => {
server.listen(port, host, () => resolvePromise());
server.on("error", reject);
});
return server;
}
function handleRouteError(response, error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`Functions dev request failed: ${message}\n`);
if (!response.headersSent && !response.writableEnded) {
sendJson(response, 500, { error: message }, baseCorsHeaders());
return;
}
if (!response.writableEnded) {
response.end();
}
}
async function waitForRuntime(bridge, manifestStore, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (bridge.isRuntimeConnected()) {
await manifestStore.load();
return true;
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
}
await manifestStore.load();
return bridge.isRuntimeConnected();
}
function getRuntimeStartupTimeoutMs() {
const rawValue = process.env.BROWSERBASE_FUNCTIONS_DEV_STARTUP_TIMEOUT_MS;
if (!rawValue) {
return DEFAULT_RUNTIME_STARTUP_TIMEOUT_MS;
}
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed < 0) {
fail("BROWSERBASE_FUNCTIONS_DEV_STARTUP_TIMEOUT_MS must be a non-negative number.");
}
return parsed;
}
async function routeRequest(request, response, bridge, manifestStore, sessionManager) {
const method = request.method || "GET";
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
const path = url.pathname;
const corsHeaders = corsHeadersForRequest(request);
if (!corsHeaders) {
sendForbiddenOrigin(response);
return;
}
if (method === "OPTIONS") {
sendNoContent(response, 204, corsHeaders);
return;
}
if (method === "GET" && path === "/") {
sendJson(response, 200, { ok: true }, corsHeaders);
return;
}
if (method === "GET" && path === "/2018-06-01/runtime/invocation/next") {
bridge.holdNextConnection(response, corsHeaders);
return;
}
const invokeMatch = path.match(/^\/v1\/functions\/([^/]+)\/invoke$/);
if (method === "POST" && invokeMatch?.[1]) {
await manifestStore.load();
const functionName = invokeMatch[1];
const manifest = manifestStore.get(functionName);
if (!manifest) {
sendJson(response, 404, {
error: `Function "${functionName}" was not found in .browserbase/functions/manifests.`,
}, corsHeaders);
return;
}
if (bridge.hasActiveInvocation()) {
sendJson(response, 503, { error: "Another invocation is already in progress." }, corsHeaders);
return;
}
let body;
try {
body = await readJsonBody(request);
}
catch (error) {
sendJson(response, 400, {
error: error instanceof Error ? error.message : "Invalid JSON body.",
}, corsHeaders);
return;
}
const params = body && typeof body === "object" && !Array.isArray(body)
? body.params || {}
: {};
const session = await sessionManager.createSession(manifest.config?.sessionConfig);
const accepted = bridge.triggerInvocation(functionName, params, { session }, corsHeaders, response);
if (!accepted) {
await sessionManager.closeSession(session.id);
sendJson(response, 503, { error: "No runtime is connected yet." }, corsHeaders);
}
return;
}
const responseMatch = path.match(/^\/2018-06-01\/runtime\/invocation\/([^/]+)\/response$/);
if (method === "POST" && responseMatch?.[1]) {
const requestId = responseMatch[1];
let payload;
try {
payload = await readJsonBody(request);
}
catch (error) {
const message = `Invalid runtime response payload: ${formatErrorMessage(error)}`;
const completed = await bridge.completeWithError(requestId, {
errorMessage: message,
errorType: "RuntimeResponseError",
stackTrace: [],
});
sendJson(response, 400, completed ? { error: message } : { error: "Request ID mismatch." }, corsHeaders);
return;
}
const completed = await bridge.completeWithSuccess(requestId, payload);
sendJson(response, completed ? 202 : 400, completed ? { ok: true } : { error: "Request ID mismatch." }, corsHeaders);
return;
}
const errorMatch = path.match(/^\/2018-06-01\/runtime\/invocation\/([^/]+)\/error$/);
if (method === "POST" && errorMatch?.[1]) {
const requestId = errorMatch[1];
let payload;
try {
payload = (await readJsonBody(request));
}
catch (error) {
const message = `Invalid runtime error payload: ${formatErrorMessage(error)}`;
const completed = await bridge.completeWithError(requestId, {
errorMessage: message,
errorType: "RuntimeResponseError",
stackTrace: [],
});
sendJson(response, 400, completed ? { error: message } : { error: "Request ID mismatch." }, corsHeaders);
return;
}
const completed = await bridge.completeWithError(requestId, {
errorMessage: payload?.errorMessage || "Unknown runtime error",
errorType: payload?.errorType || "RuntimeError",
stackTrace: Array.isArray(payload?.stackTrace) ? payload.stackTrace : [],
});
sendJson(response, completed ? 202 : 400, completed ? { ok: true } : { error: "Request ID mismatch." }, corsHeaders);
return;
}
sendJson(response, 404, { error: "Not found." }, corsHeaders);
}
function formatErrorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
async function waitForChildSpawn(child) {
await new Promise((resolvePromise, reject) => {
const cleanup = () => {
child.off("error", onError);
child.off("spawn", onSpawn);
};
const onError = (error) => {
cleanup();
reject(error);
};
const onSpawn = () => {
cleanup();
resolvePromise();
};
child.once("error", onError);
child.once("spawn", onSpawn);
});
}
async function readJsonBody(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
if (chunks.length === 0) {
return {};
}
const text = Buffer.concat(chunks).toString("utf8");
if (!text) {
return {};
}
return JSON.parse(text);
}
function sendJson(response, statusCode, body, corsHeaders) {
response.writeHead(statusCode, {
...corsHeaders,
"content-type": "application/json",
});
response.end(JSON.stringify(body));
}
function sendNoContent(response, statusCode, corsHeaders) {
response.writeHead(statusCode, corsHeaders);
response.end();
}
function sendForbiddenOrigin(response) {
response.writeHead(403, {
"content-type": "application/json",
vary: "Origin",
});
response.end(JSON.stringify({ error: "Origin is not allowed." }));
}
function corsHeadersForRequest(request) {
const origin = request.headers.origin;
if (origin === undefined)
return baseCorsHeaders();
if (Array.isArray(origin))
return null;
if (!isAllowedLoopbackOrigin(origin))
return null;
return {
...baseCorsHeaders(),
"access-control-allow-origin": origin,
vary: "Origin",
};
}
function baseCorsHeaders() {
return {
"access-control-allow-headers": "content-type",
"access-control-allow-methods": "GET, POST, OPTIONS",
};
}
function isAllowedLoopbackOrigin(origin) {
try {
const url = new URL(origin);
if (url.protocol !== "http:" && url.protocol !== "https:")
return false;
return (url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "[::1]");
}
catch {
return false;
}
}