diff --git a/packages/sdk/scripts/mintlify-post-processing/types-to-delete-after-processing.json b/packages/sdk/scripts/mintlify-post-processing/types-to-delete-after-processing.json index 5e5db11..fb8c123 100644 --- a/packages/sdk/scripts/mintlify-post-processing/types-to-delete-after-processing.json +++ b/packages/sdk/scripts/mintlify-post-processing/types-to-delete-after-processing.json @@ -2,6 +2,8 @@ "AiGatewayConnection", "ActorConnectOptions", "AppPublicSettingsResponse", + "BrowserSession", + "BrowserSessionOptions", "DeleteManyResult", "DeleteResult", "EntityAggregateResult", diff --git a/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json b/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json index 305dc66..94c94ba 100644 --- a/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json +++ b/packages/sdk/scripts/mintlify-post-processing/types-to-expose.json @@ -19,6 +19,9 @@ "AppPublicSettings", "AppPublicSettingsResponse", "AuthModule", + "BrowserModule", + "BrowserSession", + "BrowserSessionOptions", "ConnectorApiRequest", "ConnectorApiResponse", "ConnectorApiResponsePhase", diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index ce72e1a..34c30f2 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -3,6 +3,7 @@ import { createEntitiesModule } from "./modules/entities.js"; import { createIntegrationsModule } from "./modules/integrations.js"; import { createAuthModule } from "./modules/auth.js"; import { createSsoModule } from "./modules/sso.js"; +import { createBrowserModule } from "./modules/browser.js"; import { createConnectorsModule, createUserConnectorsModule, @@ -293,6 +294,7 @@ export function createClient(config: CreateClientConfig): Base44Client { }), integrations: createIntegrationsModule(serviceRoleAxiosClient, appId), sso: createSsoModule(serviceRoleAxiosClient, appId), + browser: createBrowserModule(serviceRoleAxiosClient), connectors: createConnectorsModule(serviceRoleAxiosClient, appId), functions: createFunctionsModule(serviceRoleFunctionsAxiosClient, appId, { getAuthHeaders: () => { diff --git a/packages/sdk/src/client.types.ts b/packages/sdk/src/client.types.ts index a0fc02d..1fd2c34 100644 --- a/packages/sdk/src/client.types.ts +++ b/packages/sdk/src/client.types.ts @@ -2,6 +2,7 @@ import type { EntitiesModule } from "./modules/entities.types.js"; import type { IntegrationsModule } from "./modules/integrations.types.js"; import type { AuthModule } from "./modules/auth.types.js"; import type { SsoModule } from "./modules/sso.types.js"; +import type { BrowserModule } from "./modules/browser.types.js"; import type { PaymentsModule, ServiceRolePaymentsModule, @@ -239,6 +240,8 @@ export interface Base44Client { aiGateway: AiGatewayModule; /** {@link AppLogsModule | App logs module} with elevated permissions. */ appLogs: AppLogsModule; + /** {@link BrowserModule | Browser module} for remote browser sessions. */ + browser: BrowserModule; /** {@link ConnectorsModule | Connectors module} for OAuth token retrieval. */ connectors: ConnectorsModule; /** {@link EntitiesModule | Entities module} with elevated permissions. */ diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e8bad29..b6658f0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -168,6 +168,12 @@ export type { SubscriptionResult, } from "./modules/payments.types.js"; +export type { + BrowserModule, + BrowserSession, + BrowserSessionOptions, +} from "./modules/browser.types.js"; + export { Actor, type Conn } from "./actor.js"; export type { diff --git a/packages/sdk/src/modules/browser.ts b/packages/sdk/src/modules/browser.ts new file mode 100644 index 0000000..a90aaf9 --- /dev/null +++ b/packages/sdk/src/modules/browser.ts @@ -0,0 +1,59 @@ +import { AxiosInstance } from "axios"; +import { + BrowserModule, + BrowserSession, + BrowserSessionOptions, +} from "./browser.types.js"; + +interface BrowserSessionResponse { + session_id: string; + connection_url: string; + connection_scope: BrowserSession["connectionScope"]; + connection_expires_at?: string; + session_expires_at?: string; +} + +/** + * Creates the Browser module for the Base44 SDK. + * + * @param axios - Axios instance (should be service role client) + * @returns Browser module with methods to create and end remote browser sessions + * @internal + */ +export function createBrowserModule(axios: AxiosInstance): BrowserModule { + const endSession = async (sessionId: string): Promise => { + if (!sessionId || typeof sessionId !== "string") { + throw new Error("Session ID is required and must be a string"); + } + + await axios.delete(`/v1/browser-sessions/${encodeURIComponent(sessionId)}`); + }; + + return { + async getSession( + options: BrowserSessionOptions = {} + ): Promise { + // The app is derived from the service token, so the route isn't app-scoped. + const response = await axios.post( + "/v1/browser-sessions", + { capture_debug_artifacts: options.captureDebugArtifacts ?? true } + ); + + const data = response as unknown as BrowserSessionResponse; + return { + sessionId: data.session_id, + connectionUrl: data.connection_url, + connectionScope: data.connection_scope, + ...(data.connection_expires_at && { + connectionExpiresAt: data.connection_expires_at, + }), + ...(data.session_expires_at && { + sessionExpiresAt: data.session_expires_at, + }), + end: () => endSession(data.session_id), + }; + }, + + endSession, + }; +} diff --git a/packages/sdk/src/modules/browser.types.ts b/packages/sdk/src/modules/browser.types.ts new file mode 100644 index 0000000..f60d250 --- /dev/null +++ b/packages/sdk/src/modules/browser.types.ts @@ -0,0 +1,119 @@ +/** + * Options for {@linkcode BrowserModule.getSession | getSession()}. + */ +export interface BrowserSessionOptions { + /** + * Whether to capture browser recordings and diagnostic logs for the session. + * + * Captured artifacts stay with the remote browser provider and can't be retrieved through the SDK. Defaults to `true`. + */ + captureDebugArtifacts?: boolean; +} + +/** + * A remote browser session returned by {@linkcode BrowserModule.getSession | getSession()}. + */ +export interface BrowserSession { + /** + * Opaque session identifier issued to your app. + * + * Pass it unchanged to {@linkcode BrowserModule.endSession | endSession()} to end the session from a different request. + */ + sessionId: string; + /** + * Signed WebSocket URL for connecting to the browser over the Chrome DevTools Protocol (CDP). + * + * Treat the complete URL as a credential. Don't log it or send it to the frontend. + */ + connectionUrl: string; + /** + * Scope of the CDP connection. + * + * - `'browser'`: The socket controls the whole browser. + * - `'page'`: The socket controls a single page. + */ + connectionScope: "browser" | "page"; + /** ISO 8601 timestamp when the connection URL expires. Omitted when the provider doesn't expose it. */ + connectionExpiresAt?: string; + /** ISO 8601 timestamp when the browser session expires. Omitted when the provider doesn't expose it. */ + sessionExpiresAt?: string; + /** + * Ends the session. Equivalent to calling {@linkcode BrowserModule.endSession | endSession()} with this session's ID. + */ + end: () => Promise; +} + +/** + * Browser module for running browser automation from your backend. + * + * This module creates remote browser sessions that you can control with a CDP client library such as Playwright or Puppeteer. Base44 doesn't store browser state, so each session is independent. End each session when you're done with it. Otherwise it keeps running until the remote browser provider times it out. + * + * This module is only available to use with a client in service role authentication mode, which means it can only be used in backend environments. + */ +export interface BrowserModule { + /** + * Creates a new remote browser session and returns its connection details. + * + * Each call creates a fresh session. Calls aren't retried automatically, and repeating a failed call doesn't resume a previous session. Session creation is rate limited per app. + * + * @param options - Optional session settings. + * @returns Promise resolving to the new browser session. + * + * @example + * ```typescript + * // Open a page in a remote browser with Playwright + * import { createClientFromRequest } from 'npm:@base44/sdk'; + * import { chromium } from 'npm:playwright-core'; + * + * Deno.serve(async (req) => { + * const base44 = createClientFromRequest(req); + * const session = await base44.asServiceRole.browser.getSession(); + * + * try { + * const browser = await chromium.connectOverCDP(session.connectionUrl); + * const page = browser.contexts()[0].pages()[0] ?? (await browser.newPage()); + * await page.goto('https://example.com'); + * const title = await page.title(); + * await browser.close(); + * + * return Response.json({ title }); + * } finally { + * await session.end(); + * } + * }); + * ``` + * + * @example + * ```typescript + * // Turn off recordings and diagnostic logs + * const session = await base44.asServiceRole.browser.getSession({ + * captureDebugArtifacts: false, + * }); + * ``` + */ + getSession(options?: BrowserSessionOptions): Promise; + + /** + * Ends a browser session created by your app. + * + * Use this when the session was created in a different request. Otherwise call `end()` on the session returned by {@linkcode getSession | getSession()}. Ending a session that has already ended succeeds, so it's safe to call more than once. The call fails with the `browser_session_not_found` error code if the ID wasn't issued to your app. + * + * @param sessionId - The `sessionId` returned by {@linkcode getSession | getSession()}, unchanged. + * @returns Promise that resolves when the session has ended. + * + * @example + * ```typescript + * // End a session whose ID was saved by an earlier request + * import { createClientFromRequest } from 'npm:@base44/sdk'; + * + * Deno.serve(async (req) => { + * const base44 = createClientFromRequest(req); + * const { sessionId } = await req.json(); + * await base44.asServiceRole.browser.endSession(sessionId); + * + * return new Response(null, { status: 204 }); + * }); + * ``` + */ + endSession(sessionId: string): Promise; +} diff --git a/packages/sdk/tests/unit/browser.test.ts b/packages/sdk/tests/unit/browser.test.ts new file mode 100644 index 0000000..d67e423 --- /dev/null +++ b/packages/sdk/tests/unit/browser.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import nock from "nock"; +import { createClient, Base44Error } from "../../src/index.ts"; + +describe("Browser module", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const serviceToken = "service-token-123"; + const connectionUrl = "wss://connect.example.com/?signingKey=session-credential"; + const sessionId = + "BB1909cef7-23e8-4394-bc31-27404bf4348f.kX9tQ2mVb7LwR4sZ0aYcEg"; + let base44: ReturnType; + let scope: nock.Scope; + + beforeEach(() => { + base44 = createClient({ serverUrl, appId, serviceToken }); + scope = nock(serverUrl); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test("getSession posts with the service token and maps the response", async () => { + scope + .post("/api/v1/browser-sessions", { capture_debug_artifacts: true }) + .matchHeader("Authorization", `Bearer ${serviceToken}`) + .reply(201, { + session_id: sessionId, + connection_url: connectionUrl, + connection_scope: "browser", + connection_expires_at: "2030-01-01T12:00:00Z", + session_expires_at: "2030-01-01T13:00:00Z", + }); + + const session = await base44.asServiceRole.browser.getSession(); + + expect(session).toEqual({ + sessionId, + connectionUrl, + connectionScope: "browser", + connectionExpiresAt: "2030-01-01T12:00:00Z", + sessionExpiresAt: "2030-01-01T13:00:00Z", + end: expect.any(Function), + }); + expect(scope.isDone()).toBe(true); + }); + + test("getSession forwards captureDebugArtifacts", async () => { + scope + .post("/api/v1/browser-sessions", { capture_debug_artifacts: false }) + .reply(201, { + session_id: sessionId, + connection_url: connectionUrl, + connection_scope: "page", + }); + + const session = await base44.asServiceRole.browser.getSession({ + captureDebugArtifacts: false, + }); + + expect(session).toEqual({ + sessionId, + connectionUrl, + connectionScope: "page", + end: expect.any(Function), + }); + expect(scope.isDone()).toBe(true); + }); + + test("getSession surfaces the error code and doesn't retry", async () => { + scope.post("/api/v1/browser-sessions").once().reply(429, { + error: { + code: "browser_capacity_exceeded", + message: "Browser capacity is temporarily unavailable.", + }, + }); + + const error = await base44.asServiceRole.browser + .getSession() + .catch((e) => e); + + expect(error).toBeInstanceOf(Base44Error); + expect(error.status).toBe(429); + expect(error.code).toBe("browser_capacity_exceeded"); + expect(scope.isDone()).toBe(true); + }); + + test("endSession deletes the session with the service token", async () => { + scope + .delete(`/api/v1/browser-sessions/${sessionId}`) + .matchHeader("Authorization", `Bearer ${serviceToken}`) + .reply(204); + + await expect( + base44.asServiceRole.browser.endSession(sessionId) + ).resolves.toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); + + test("endSession encodes the session ID in the path", async () => { + scope.delete("/api/v1/browser-sessions/a%2Fb%3Fc").reply(204); + + await base44.asServiceRole.browser.endSession("a/b?c"); + expect(scope.isDone()).toBe(true); + }); + + test("session.end() ends the session it was returned with", async () => { + scope + .post("/api/v1/browser-sessions") + .reply(201, { + session_id: sessionId, + connection_url: connectionUrl, + connection_scope: "browser", + }) + .delete(`/api/v1/browser-sessions/${sessionId}`) + .reply(204); + + const session = await base44.asServiceRole.browser.getSession(); + await session.end(); + + expect(scope.isDone()).toBe(true); + }); + + test("endSession surfaces browser_session_not_found", async () => { + scope.delete(`/api/v1/browser-sessions/${sessionId}`).reply(404, { + error: { + code: "browser_session_not_found", + message: "Browser session not found.", + }, + }); + + const error = await base44.asServiceRole.browser + .endSession(sessionId) + .catch((e) => e); + + expect(error).toBeInstanceOf(Base44Error); + expect(error.status).toBe(404); + expect(error.code).toBe("browser_session_not_found"); + }); + + test.each([undefined, "", 123])( + "endSession rejects %j without sending a request", + async (invalid) => { + await expect( + base44.asServiceRole.browser.endSession(invalid as unknown as string) + ).rejects.toThrow("Session ID is required and must be a string"); + expect(nock.pendingMocks()).toEqual([]); + } + ); + + test("browser is not available without a service token", () => { + const userClient = createClient({ serverUrl, appId, token: "user-token" }); + expect(() => userClient.asServiceRole.browser).toThrow( + /Service token is required/ + ); + }); +});