← 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(' ')