Skip to content
← Public packages

@kody/browser-run

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

src/client.ts

354 lines · 10.5 KB · TypeScript
/**
 * Shared Cloudflare Browser Run REST/CDP helpers.
 * Auth is secret-backed via `{{secret:name}}` placeholders — never raw tokens.
 * Do not import `@cloudflare/puppeteer` or Playwright here (isolate blow-up).
 */

export const API_HOST = 'api.cloudflare.com'
export const API_BASE = `https://${API_HOST}`
export const DEFAULT_API_TOKEN_SECRET = 'cloudflareApiToken'
export const BROWSER_RENDERING_PREFIX = '/client/v4/accounts'

export const DOCS_HOME = 'https://developers.cloudflare.com/browser-run/'
export const DOCS_REUSE_SESSIONS =
	'https://developers.cloudflare.com/browser-run/features/reuse-sessions/'
export const DOCS_QUICK_ACTIONS =
	'https://developers.cloudflare.com/browser-run/quick-actions/'
export const DOCS_CDP = 'https://developers.cloudflare.com/browser-run/cdp/'
export const DOCS_CDP_PUPPETEER =
	'https://developers.cloudflare.com/browser-run/cdp/puppeteer/'

export const TOKEN_SETUP_URL =
	'https://kody.codes/account/secrets/new?name=cloudflareApiToken&description=Cloudflare%20API%20token%20with%20Browser%20Rendering%20-%20Edit&allowedHosts=api.cloudflare.com&scope=user'

export const ACCOUNT_ID_HELP =
	'Find your Cloudflare account id in the dashboard URL (dash.cloudflare.com/<accountId>/…) or Workers & Pages → Overview → Account ID.'

const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/

export type AuthOptions = {
	/** Cloudflare account id (required for live API calls). */
	accountId?: string
	/**
	 * Alternate Kody user secret **name** only (not a token value).
	 * Defaults to `cloudflareApiToken`.
	 */
	apiTokenSecret?: string
}

export type BrowserRunJson = {
	success?: boolean
	result?: unknown
	errors?: unknown[]
	messages?: unknown[]
	[key: string]: unknown
}

export class BrowserRunError extends Error {
	status: number
	details: unknown
	setupUrl: string

	constructor(
		message: string,
		input: { status?: number; details?: unknown; setupUrl?: string } = {},
	) {
		super(message)
		this.name = 'BrowserRunError'
		this.status = input.status ?? 0
		this.details = input.details ?? null
		this.setupUrl = input.setupUrl ?? TOKEN_SETUP_URL
	}
}

export function clean(value: unknown): string {
	return String(value ?? '').trim()
}

export function resolveApiTokenSecretName(options: AuthOptions = {}): string {
	const explicit = clean(options.apiTokenSecret)
	if (explicit) {
		if (!SECRET_NAME.test(explicit)) {
			throw new Error(
				'apiTokenSecret must be a simple Kody secret name (letters, numbers, underscore).',
			)
		}
		return explicit
	}
	return DEFAULT_API_TOKEN_SECRET
}

export function secretSetupUrl(secretName: string = DEFAULT_API_TOKEN_SECRET): string {
	if (secretName === DEFAULT_API_TOKEN_SECRET) return TOKEN_SETUP_URL
	return TOKEN_SETUP_URL.replace(
		'name=cloudflareApiToken',
		'name=' + encodeURIComponent(secretName),
	)
}

export function authorizationHeader(options: AuthOptions = {}): string {
	const name = resolveApiTokenSecretName(options)
	return `Bearer {{secret:${name}}}`
}

export function authHeaders(options: AuthOptions = {}): Record<string, string> {
	return {
		Accept: 'application/json',
		Authorization: authorizationHeader(options),
		'User-Agent': 'kody-browser-run/1.0',
	}
}

export function requireAccountId(options: AuthOptions = {}): string {
	const accountId = clean(options.accountId)
	if (!accountId) {
		throw new BrowserRunError(
			`accountId is required. ${ACCOUNT_ID_HELP}`,
			{ status: 400 },
		)
	}
	if (!/^[a-f0-9]{32}$/i.test(accountId) && !/^[A-Za-z0-9_-]{8,64}$/.test(accountId)) {
		// Cloudflare account ids are typically 32-char hex; allow slightly looser for forks.
		if (accountId.length < 8 || accountId.length > 64) {
			throw new BrowserRunError(
				`accountId looks invalid (${accountId.length} chars). ${ACCOUNT_ID_HELP}`,
				{ status: 400 },
			)
		}
	}
	return accountId
}

export function accountPath(accountId: string, suffix: string): string {
	const s = suffix.startsWith('/') ? suffix : '/' + suffix
	return `${BROWSER_RENDERING_PREFIX}/${encodeURIComponent(accountId)}${s}`
}

export function browserWsEndpoint(accountId: string, sessionId?: string, keepAliveMs?: number): string {
	const base = `wss://${API_HOST}${BROWSER_RENDERING_PREFIX}/${encodeURIComponent(accountId)}/browser-rendering/devtools/browser`
	const url = new URL(sessionId ? `${base}/${encodeURIComponent(sessionId)}` : base)
	if (keepAliveMs != null && Number.isFinite(keepAliveMs)) {
		url.searchParams.set('keep_alive', String(Math.floor(keepAliveMs)))
	}
	return url.toString()
}

/** Alternate path used in some Cloudflare CDP docs (`browser-run` rename). */
export function browserWsEndpointAlias(
	accountId: string,
	sessionId?: string,
	keepAliveMs?: number,
): string {
	const base = `wss://${API_HOST}/client/v4/accounts/${encodeURIComponent(accountId)}/browser-run/devtools/browser`
	const url = new URL(sessionId ? `${base}/${encodeURIComponent(sessionId)}` : base)
	if (keepAliveMs != null && Number.isFinite(keepAliveMs)) {
		url.searchParams.set('keep_alive', String(Math.floor(keepAliveMs)))
	}
	return url.toString()
}

function arrayBufferToBase64(buffer: ArrayBuffer): string {
	const bytes = new Uint8Array(buffer)
	const chunk = 0x8000
	let binary = ''
	for (let i = 0; i < bytes.length; i += chunk) {
		binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
	}
	return btoa(binary)
}

export type BrowserRunResponse =
	| {
			ok: boolean
			status: number
			contentType: string
			kind: 'json'
			body: BrowserRunJson
			result: unknown
			errors: unknown[]
			messages: unknown[]
			browserMsUsed: string | null
	  }
	| {
			ok: boolean
			status: number
			contentType: string
			kind: 'binary'
			base64: string
			byteLength: number
			browserMsUsed: string | null
			result: null
			errors: unknown[]
			messages: unknown[]
	  }
	| {
			ok: boolean
			status: number
			contentType: string
			kind: 'text'
			text: string
			browserMsUsed: string | null
			result: null
			errors: unknown[]
			messages: unknown[]
	  }

export async function browserRunFetch(
	path: string,
	options: AuthOptions & {
		method?: string
		query?: Record<string, string | number | boolean | null | undefined>
		body?: unknown
		acceptBinary?: boolean
	} = {},
): Promise<BrowserRunResponse> {
	const method = clean(options.method || 'GET').toUpperCase() || 'GET'
	const url = new URL(path.startsWith('http') ? path : path, API_BASE)
	if (options.query) {
		for (const [key, value] of Object.entries(options.query)) {
			if (value === undefined || value === null) continue
			url.searchParams.set(key, String(value))
		}
	}
	const headers: Record<string, string> = { ...authHeaders(options) }
	const init: RequestInit = { method, headers }
	if (options.body !== undefined && method !== 'GET' && method !== 'HEAD') {
		headers['Content-Type'] = 'application/json'
		init.body = JSON.stringify(options.body)
	}

	const response = await fetch(url.toString(), init)
	const contentType = (response.headers.get('content-type') || '').toLowerCase()
	const browserMsUsed = response.headers.get('x-browser-ms-used')

	const isBinary =
		options.acceptBinary === true ||
		contentType.startsWith('image/') ||
		contentType.includes('application/pdf') ||
		contentType.includes('octet-stream')

	if (isBinary && response.ok) {
		const buffer = await response.arrayBuffer()
		return {
			ok: true,
			status: response.status,
			contentType: contentType || 'application/octet-stream',
			kind: 'binary',
			base64: arrayBufferToBase64(buffer),
			byteLength: buffer.byteLength,
			browserMsUsed,
			result: null,
			errors: [],
			messages: [],
		}
	}

	const text = await response.text()
	if (!text.trim()) {
		return {
			ok: response.ok,
			status: response.status,
			contentType,
			kind: 'text',
			text: '',
			browserMsUsed,
			result: null,
			errors: [],
			messages: [],
		}
	}

	if (contentType.includes('application/json') || text.trim().startsWith('{') || text.trim().startsWith('[')) {
		let body: BrowserRunJson
		try {
			body = JSON.parse(text) as BrowserRunJson
		} catch {
			throw new BrowserRunError(
				`Cloudflare Browser Run returned non-JSON (${response.status}).`,
				{ status: response.status, details: text.slice(0, 500) },
			)
		}
		const ok = response.ok && body.success !== false
		if (!ok) {
			const errMsg =
				Array.isArray(body.errors) && body.errors.length
					? JSON.stringify(body.errors)
					: `HTTP ${response.status}`
			const hint =
				response.status === 401 || response.status === 403
					? ` Save a token with Browser Rendering - Edit at ${secretSetupUrl(resolveApiTokenSecretName(options))}.`
					: ''
			throw new BrowserRunError(`Browser Run API error: ${errMsg}.${hint}`, {
				status: response.status,
				details: body,
				setupUrl: secretSetupUrl(resolveApiTokenSecretName(options)),
			})
		}
		return {
			ok: true,
			status: response.status,
			contentType: contentType || 'application/json',
			kind: 'json',
			body,
			result: body.result ?? body,
			errors: Array.isArray(body.errors) ? body.errors : [],
			messages: Array.isArray(body.messages) ? body.messages : [],
			browserMsUsed,
		}
	}

	if (!response.ok) {
		throw new BrowserRunError(`Browser Run HTTP ${response.status}: ${text.slice(0, 300)}`, {
			status: response.status,
			details: text.slice(0, 1000),
			setupUrl: secretSetupUrl(resolveApiTokenSecretName(options)),
		})
	}

	return {
		ok: true,
		status: response.status,
		contentType,
		kind: 'text',
		text,
		browserMsUsed,
		result: null,
		errors: [],
		messages: [],
	}
}

export const QUICK_ACTIONS = [
	'content',
	'screenshot',
	'pdf',
	'markdown',
	'scrape',
	'links',
	'json',
	'snapshot',
	'accessibilityTree',
] as const

export type QuickActionName = (typeof QUICK_ACTIONS)[number]

export function isQuickAction(value: string): value is QuickActionName {
	return (QUICK_ACTIONS as readonly string[]).includes(value)
}

export function pickAuth(params: AuthOptions = {}): AuthOptions {
	const out: AuthOptions = {}
	if (params.accountId !== undefined) out.accountId = params.accountId
	if (params.apiTokenSecret !== undefined) out.apiTokenSecret = params.apiTokenSecret
	return out
}

export const ISOLATION_INSTRUCTIONS = [
	'Connect with puppeteer-core / @cloudflare/puppeteer using browserWSEndpoint and headers.Authorization Bearer <token>.',
	'Immediately call browser.createBrowserContext() for this request — never share cookies/storage across clients.',
	'Do work inside that context (context.newPage(), …).',
	'When finished: await context.close(); then await browser.disconnect().',
	'Never call browser.close() on a shared session — that kills every concurrent client.',
	'Requires @cloudflare/puppeteer ≥ 1.1.0 (or Playwright ≥ 1.3.0) for concurrent clients on one session.',
	`Docs: ${DOCS_REUSE_SESSIONS}`,
].join(' ')