Skip to content
← Public packages

@kody/browser-run

Call Cloudflare Browser Run Quick Actions and reuse shared sessions with createBrowserContext.

src/sessions/acquire.ts

115 lines · 3.6 KB · TypeScript
import { any, boolean, number, object, optional, parse, string } from 'remix/data-schema'
import {
	ISOLATION_INSTRUCTIONS,
	accountPath,
	authorizationHeader,
	browserRunFetch,
	browserWsEndpoint,
	browserWsEndpointAlias,
	pickAuth,
	requireAccountId,
} from '../client.ts'

const acquireInput = object(
	{
		accountId: string(),
		apiTokenSecret: optional(string()),
		keepAliveMs: optional(number()),
		lab: optional(boolean()),
		recording: optional(boolean()),
		targets: optional(boolean()),
		guardrails: optional(any()),
		dryRun: optional(boolean()),
	},
	{ unknownKeys: 'error' },
)

/**
 * Acquire / launch a new Browser Run session
 * (`POST .../devtools/browser`) and return `{ sessionId, browserWSEndpoint }`.
 * For the pit-of-success shared path (list → reuse → launch), prefer
 * `./open-isolated` instead of calling acquire alone in a loop.
 *
 * @param raw.accountId - Cloudflare account id
 * @param raw.keepAliveMs - Keep-alive ms for the new session (10_000–1_200_000)
 * @param raw.guardrails - Optional outbound domain guardrails body
 * @param raw.dryRun - Preview without launching
 * @param raw.apiTokenSecret - Optional alternate secret name
 * @returns Session id, WebSocket endpoint, and Authorization guidance
 *
 * @example
 * import acquire from 'kody:@kody/browser-run/sessions/acquire'
 * const session = await acquire({
 *   accountId: 'YOUR_ACCOUNT_ID',
 *   keepAliveMs: 600000,
 * })
 */
export default async function acquireSession(raw: unknown = {}) {
	const input = parse(acquireInput, raw ?? {})
	const auth = pickAuth(input)
	const accountId = requireAccountId(auth)
	const path = accountPath(accountId, '/browser-rendering/devtools/browser')
	const query: Record<string, string | number | boolean> = {}
	if (input.keepAliveMs != null) query.keep_alive = Math.floor(input.keepAliveMs)
	if (input.lab === true) query.lab = true
	if (input.recording === true) query.recording = true
	if (input.targets === true) query.targets = true

	const body =
		input.guardrails != null
			? { guardrails: input.guardrails }
			: undefined

	if (input.dryRun === true) {
		return {
			dryRun: true as const,
			method: 'POST' as const,
			path,
			query,
			body: body ?? null,
			accountId,
			note: 'Would launch a new Browser Run session. Prefer open-isolated to reuse first.',
		}
	}

	const response = await browserRunFetch(path, {
		...auth,
		method: 'POST',
		query,
		body,
	})

	const result =
		(response.kind === 'json' ? (response.result as Record<string, unknown>) : null) ||
		{}
	const sessionId = String(result.sessionId ?? result.session_id ?? '').trim()
	const fromApi = String(
		result.webSocketDebuggerUrl ?? result.browserWSEndpoint ?? '',
	).trim()
	const browserWSEndpoint =
		fromApi ||
		(sessionId
			? browserWsEndpoint(accountId, sessionId, input.keepAliveMs)
			: browserWsEndpoint(accountId, undefined, input.keepAliveMs))

	return {
		ok: true,
		accountId,
		sessionId: sessionId || null,
		browserWSEndpoint,
		browserWSEndpointAlias: browserWsEndpointAlias(
			accountId,
			sessionId || undefined,
			input.keepAliveMs,
		),
		authorizationHeaderTemplate: authorizationHeader(auth),
		authorizationGuidance:
			'Pass headers: { Authorization: "Bearer <cloudflareApiToken>" } to puppeteer.connect / connectOverCDP. In Kody, the Authorization placeholder resolves on approved hosts; for external Node use the real token from your environment — never paste tokens into chat.',
		launched: true,
		isolation: 'createBrowserContext' as const,
		disconnectNotClose: true as const,
		keepAliveMs: input.keepAliveMs ?? null,
		instructions: ISOLATION_INSTRUCTIONS,
		browserMsUsed: response.browserMsUsed,
	}
}