Skip to content
← Public packages

@kody/browser-run

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

src/open-isolated.ts

140 lines · 4.5 KB · TypeScript
import { boolean, number, object, optional, parse, string } from 'remix/data-schema'
import {
	ISOLATION_INSTRUCTIONS,
	authorizationHeader,
	browserWsEndpoint,
	browserWsEndpointAlias,
	pickAuth,
	requireAccountId,
} from './client.ts'
import acquireSession from './sessions/acquire.ts'
import listSessions from './sessions/list.ts'

const openInput = object(
	{
		accountId: string(),
		apiTokenSecret: optional(string()),
		keepAliveMs: optional(number()),
		/** When true, only list + plan reuse; do not POST acquire. */
		dryRun: optional(boolean()),
		/** Force launch even if sessions exist. */
		forceLaunch: optional(boolean()),
	},
	{ unknownKeys: 'error' },
)

function shuffleStart<T>(items: T[]): T[] {
	if (items.length <= 1) return items.slice()
	const start = Math.floor(Math.random() * items.length)
	return [...items.slice(start), ...items.slice(0, start)]
}

function sessionIdOf(session: unknown): string | null {
	if (!session || typeof session !== 'object') return null
	const s = session as Record<string, unknown>
	const id = s.sessionId ?? s.session_id ?? s.id
	return id != null ? String(id).trim() || null : null
}

function wsOf(session: unknown): string | null {
	if (!session || typeof session !== 'object') return null
	const s = session as Record<string, unknown>
	const url = s.webSocketDebuggerUrl ?? s.browserWSEndpoint
	return url != null ? String(url).trim() || null : null
}

/**
 * DEFAULT shared-session path (pit of success): list active sessions, try to
 * reuse one (random start order like Cloudflare docs), otherwise acquire a
 * new session. Returns connect info plus hard instructions to
 * `createBrowserContext()`, then `context.close()` + `browser.disconnect()` —
 * never `browser.close()` while sharing.
 *
 * Puppeteer/Playwright run outside Kody (Worker or Node). This export only
 * prepares session + CDP connection metadata via the REST API.
 *
 * @param raw.accountId - Cloudflare account id
 * @param raw.keepAliveMs - Keep-alive when launching
 * @param raw.forceLaunch - Skip reuse and acquire fresh
 * @param raw.dryRun - List + plan only; never acquire
 * @param raw.apiTokenSecret - Optional alternate secret name
 * @returns `{ sessionId, browserWSEndpoint, launched, isolation, instructions }`
 *
 * @example
 * import openIsolated from 'kody:@kody/browser-run/open-isolated'
 * const session = await openIsolated({ accountId: 'YOUR_ACCOUNT_ID' })
 * // Connect externally, then createBrowserContext → work → context.close → disconnect
 */
export default async function openIsolated(raw: unknown = {}) {
	const input = parse(openInput, raw ?? {})
	const auth = pickAuth(input)
	const accountId = requireAccountId(auth)

	const listed = await listSessions({
		...auth,
		accountId,
	})
	const sessions = Array.isArray(listed.sessions) ? listed.sessions : []
	const ordered = shuffleStart(sessions)

	if (input.dryRun === true) {
		return {
			dryRun: true as const,
			accountId,
			activeSessionCount: sessions.length,
			candidateSessionIds: ordered.map(sessionIdOf).filter(Boolean),
			wouldReuse: !input.forceLaunch && ordered.length > 0,
			wouldLaunch: input.forceLaunch === true || ordered.length === 0,
			isolation: 'createBrowserContext' as const,
			disconnectNotClose: true as const,
			instructions: ISOLATION_INSTRUCTIONS,
		}
	}

	if (!input.forceLaunch) {
		for (const session of ordered) {
			const sessionId = sessionIdOf(session)
			if (!sessionId) continue
			const browserWSEndpoint =
				wsOf(session) || browserWsEndpoint(accountId, sessionId, input.keepAliveMs)
			return {
				ok: true,
				accountId,
				sessionId,
				browserWSEndpoint,
				browserWSEndpointAlias: browserWsEndpointAlias(
					accountId,
					sessionId,
					input.keepAliveMs,
				),
				authorizationHeaderTemplate: authorizationHeader(auth),
				launched: false,
				isolation: 'createBrowserContext' as const,
				disconnectNotClose: true as const,
				keepAliveMs: input.keepAliveMs ?? null,
				reusedFromList: true,
				candidatesTried: 1,
				activeSessionCount: sessions.length,
				instructions: ISOLATION_INSTRUCTIONS,
				note:
					'Listed session may have expired or be at capacity — if puppeteer.connect fails, call open-isolated again with forceLaunch: true or retry.',
			}
		}
	}

	const acquired = await acquireSession({
		...auth,
		accountId,
		keepAliveMs: input.keepAliveMs,
	})

	return {
		...acquired,
		launched: true,
		reusedFromList: false,
		activeSessionCount: sessions.length,
		isolation: 'createBrowserContext' as const,
		disconnectNotClose: true as const,
		instructions: ISOLATION_INSTRUCTIONS,
	}
}