diff --git a/examples/browser-routing.ts b/examples/browser-routing.ts new file mode 100644 index 00000000..0e4da960 --- /dev/null +++ b/examples/browser-routing.ts @@ -0,0 +1,13 @@ +import Kernel from '@onkernel/sdk'; + +async function main() { + const kernel = new Kernel(); + + const browser = await kernel.browsers.create({}); + const response = await kernel.browsers.fetch(browser.session_id, 'https://example.com', { method: 'GET' }); + console.log('status', response.status); + + await kernel.browsers.deleteByID(browser.session_id); +} + +void main(); diff --git a/src/client.ts b/src/client.ts index 7a00e5e5..636679ae 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,6 +20,11 @@ import * as Uploads from './core/uploads'; import * as API from './resources/index'; import { APIPromise } from './core/api-promise'; import { AppListParams, AppListResponse, AppListResponsesOffsetPagination, Apps } from './resources/apps'; +import { + BrowserRouteCache, + browserRoutingSubresourcesFromEnv, + createRoutingFetch, +} from './lib/browser-routing'; import { BrowserPool, BrowserPoolAcquireParams, @@ -247,9 +252,11 @@ export class Kernel { fetchOptions: MergedRequestInit | undefined; private fetch: Fetch; + private rawFetch: Fetch; #encoder: Opts.RequestEncoder; protected idempotencyHeader?: string; private _options: ClientOptions; + public browserRouteCache: BrowserRouteCache; /** * API Client for interfacing with the Kernel API. @@ -312,7 +319,13 @@ export class Kernel { defaultLogLevel; this.fetchOptions = options.fetchOptions; this.maxRetries = options.maxRetries ?? 2; - this.fetch = options.fetch ?? Shims.getDefaultFetch(); + this.rawFetch = options.fetch ?? Shims.getDefaultFetch(); + this.browserRouteCache = new BrowserRouteCache(); + this.fetch = createRoutingFetch(this.rawFetch, { + apiBaseURL: this.baseURL, + subresources: browserRoutingSubresourcesFromEnv(), + cache: this.browserRouteCache, + }); this.#encoder = Opts.FallbackEncoder; this._options = options; @@ -332,11 +345,17 @@ export class Kernel { timeout: this.timeout, logger: this.logger, logLevel: this.logLevel, - fetch: this.fetch, + fetch: this.rawFetch, fetchOptions: this.fetchOptions, apiKey: this.apiKey, ...options, }); + client.browserRouteCache = this.browserRouteCache; + client.fetch = createRoutingFetch(client.rawFetch, { + apiBaseURL: client.baseURL, + subresources: browserRoutingSubresourcesFromEnv(), + cache: client.browserRouteCache, + }); return client; } diff --git a/src/index.ts b/src/index.ts index 72d9bc02..81aa3351 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ export { Kernel as default } from './client'; export { type Uploadable, toFile } from './core/uploads'; export { APIPromise } from './core/api-promise'; export { Kernel, type ClientOptions } from './client'; +export { type BrowserFetchInit } from './lib/browser-fetch'; +export { BrowserRouteCache, type BrowserRoute } from './lib/browser-routing'; export { PagePromise } from './core/pagination'; export { KernelError, diff --git a/src/lib/browser-fetch.ts b/src/lib/browser-fetch.ts new file mode 100644 index 00000000..63cbc18e --- /dev/null +++ b/src/lib/browser-fetch.ts @@ -0,0 +1,180 @@ +import type { RequestInfo, RequestInit } from '../internal/builtin-types'; +import { KernelError } from '../core/error'; +import { buildHeaders } from '../internal/headers'; +import type { FinalRequestOptions, RequestOptions } from '../internal/request-options'; +import type { HTTPMethod } from '../internal/types'; +import { joinURL } from './join-url'; +import type { Kernel } from '../client'; + +export interface BrowserFetchInit extends RequestInit { + timeout_ms?: number; +} + +export async function browserFetch( + client: Kernel, + sessionId: string, + input: RequestInfo | URL, + init?: BrowserFetchInit, +): Promise { + const route = client.browserRouteCache.get(sessionId); + if (!route) { + throw new KernelError( + `browser route cache does not contain session ${sessionId}; create, retrieve, or list the browser before calling browser.fetch`, + ); + } + + const { url: targetURL, method, headers, body, signal, duplex, timeout_ms } = splitFetchArgs(input, init); + assertHTTPURL(targetURL); + + const query: Record = { url: targetURL, jwt: route.jwt }; + if (timeout_ms !== undefined) { + query['timeout_ms'] = timeout_ms; + } + + const accept = headers.get('accept'); + const requestOptions: FinalRequestOptions = { + method: normalizeMethod(method), + path: joinURL(route.baseURL, '/curl/raw'), + query, + body: body as RequestOptions['body'], + headers: buildHeaders([ + { Authorization: null }, + accept ? { Accept: accept } : { Accept: '*/*' }, + headersToRequestOptionsHeaders(headers), + ]), + signal: signal ?? null, + __binaryResponse: true, + }; + if (duplex) { + requestOptions.fetchOptions = { duplex } as NonNullable; + } + + return client.request(requestOptions).asResponse(); +} + +function normalizeMethod(method: string): HTTPMethod { + const methodLower = method.toLowerCase(); + const allowed = new Set(['get', 'post', 'put', 'patch', 'delete']); + if (!allowed.has(methodLower)) { + throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`); + } + return methodLower as HTTPMethod; +} + +function splitFetchArgs( + input: RequestInfo | URL, + init?: BrowserFetchInit, +): { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; +} { + const timeoutFromInit = init && 'timeout_ms' in init ? init['timeout_ms'] : undefined; + + if (input instanceof Request) { + const headers = new Headers(input.headers); + if (init?.headers) { + const extra = new Headers(init.headers); + extra.forEach((value, key) => { + headers.set(key, value); + }); + } + + const out: { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; + } = { + url: input.url, + method: (init?.method ?? input.method)?.toUpperCase() || 'GET', + headers, + }; + const body = init?.body ?? input.body; + if (body !== undefined && body !== null) { + out.body = body; + } + const signal = init?.signal ?? input.signal; + if (signal !== undefined) { + out.signal = signal; + } + if (init?.duplex !== undefined) { + out.duplex = init.duplex; + } + if (timeoutFromInit !== undefined) { + out.timeout_ms = timeoutFromInit; + } + return out; + } + + const out: { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; + } = { + url: input instanceof URL ? input.href : String(input), + method: (init?.method ?? 'GET').toUpperCase(), + headers: new Headers(init?.headers), + }; + if (init?.body !== undefined) { + out.body = init.body; + } + if (init?.signal !== undefined) { + out.signal = init.signal; + } + if (init?.duplex !== undefined) { + out.duplex = init.duplex; + } + if (timeoutFromInit !== undefined) { + out.timeout_ms = timeoutFromInit; + } + return out; +} + +function assertHTTPURL(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new KernelError(`browser.fetch target must be an absolute URL; received: ${url}`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new KernelError(`browser.fetch only supports http(s) URLs; received: ${parsed.protocol}`); + } +} + +function headersToRequestOptionsHeaders(headers: Headers): Record { + const out: Record = {}; + + headers.forEach((value, key) => { + switch (key.toLowerCase()) { + case 'accept': + case 'content-length': + case 'connection': + case 'keep-alive': + case 'proxy-authenticate': + case 'proxy-authorization': + case 'te': + case 'trailers': + case 'transfer-encoding': + case 'upgrade': + return; + default: + out[key] = value; + } + }); + + return out; +} diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts new file mode 100644 index 00000000..6580da6c --- /dev/null +++ b/src/lib/browser-routing.ts @@ -0,0 +1,329 @@ +import type { Fetch, RequestInfo, RequestInit } from '../internal/builtin-types'; +import { joinURL } from './join-url'; + +export type BrowserRoute = { + sessionId: string; + baseURL: string; + jwt: string; +}; + +export class BrowserRouteCache { + private entries = new Map(); + + get(sessionId: string): BrowserRoute | undefined { + return this.entries.get(sessionId); + } + + set(route: BrowserRoute): void { + this.entries.set(route.sessionId, route); + } + + delete(sessionId: string): void { + this.entries.delete(sessionId); + } + + clear(): void { + this.entries.clear(); + } +} + +const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; +const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl']; +const BROWSER_ROUTE_CACHEABLE_PATH = /^\/(?:v\d+\/)?browsers(?:\/[^/]+)?\/?$/; +const BROWSER_POOL_ACQUIRE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/acquire\/?$/; +const BROWSER_DELETE_BY_ID_PATH = /^\/(?:v\d+\/)?browsers\/([^/]+)\/?$/; +const BROWSER_POOL_RELEASE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/release\/?$/; + +export function browserRoutingSubresourcesFromEnv(): string[] { + const raw = readBrowserRoutingSubresourcesEnv(); + if (raw === undefined) { + return [...DEFAULT_BROWSER_ROUTING_SUBRESOURCES]; + } + + if (raw.trim() === '') { + return []; + } + + return raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + +export function createRoutingFetch( + innerFetch: Fetch, + { + apiBaseURL, + subresources, + cache, + }: { + apiBaseURL: string; + subresources: Iterable; + cache: BrowserRouteCache; + }, +): Fetch { + const allowed = new Set([...subresources].map((value) => value.trim()).filter(Boolean)); + const apiOrigin = new URL(apiBaseURL).origin; + + return async (input, init) => { + const request = new Request(input, init); + const shouldSniff = shouldSniffAndPopulateCache(request, apiOrigin); + const response = await routeRequest(innerFetch, { input, init, request }, apiOrigin, allowed, cache); + if (shouldSniff) { + await sniffAndPopulateCache(response, cache); + } + await maybeEvictBrowserRoute(request, response, apiOrigin, cache); + return response; + }; +} + +function shouldSniffAndPopulateCache(request: Request, apiOrigin: string): boolean { + const url = new URL(request.url); + return ( + url.origin === apiOrigin && + (BROWSER_ROUTE_CACHEABLE_PATH.test(url.pathname) || BROWSER_POOL_ACQUIRE_PATH.test(url.pathname)) + ); +} + +async function maybeEvictBrowserRoute( + request: Request, + response: Response, + apiOrigin: string, + cache: BrowserRouteCache, +): Promise { + if (!response.ok) { + return; + } + + const url = new URL(request.url); + if (url.origin !== apiOrigin) { + return; + } + + const sessionId = + deletedSessionIdFromPath(request, url.pathname) ?? + (await releasedSessionIdFromRequest(request, url.pathname)); + if (sessionId) { + cache.delete(sessionId); + } +} + +function deletedSessionIdFromPath(request: Request, pathname: string): string | undefined { + if (request.method.toUpperCase() !== 'DELETE') { + return undefined; + } + + const match = pathname.match(BROWSER_DELETE_BY_ID_PATH); + if (!match) { + return undefined; + } + + const sessionId = decodeURIComponent(match[1] ?? ''); + return sessionId || undefined; +} + +async function releasedSessionIdFromRequest(request: Request, pathname: string): Promise { + if (request.method.toUpperCase() !== 'POST' || !BROWSER_POOL_RELEASE_PATH.test(pathname)) { + return undefined; + } + + try { + const body = await request.clone().json(); + if (!body || typeof body !== 'object') { + return undefined; + } + + const sessionId = (body as Record)['session_id']; + return typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : undefined; + } catch { + return undefined; + } +} + +function browserRouteFromValue(value: unknown): BrowserRoute | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const record = value as Record; + const sessionId = typeof record['session_id'] === 'string' ? record['session_id'].trim() : ''; + const baseURL = typeof record['base_url'] === 'string' ? record['base_url'].trim() : ''; + if (!sessionId || !baseURL) { + return undefined; + } + + const explicitJWT = typeof record['jwt'] === 'string' ? record['jwt'].trim() : ''; + const cdpWsURL = typeof record['cdp_ws_url'] === 'string' ? record['cdp_ws_url'] : undefined; + const jwt = explicitJWT || parseJwtFromCdpWsUrl(cdpWsURL) || ''; + if (!jwt) { + return undefined; + } + return { + sessionId, + baseURL, + jwt, + }; +} + +async function sniffAndPopulateCache(response: Response, cache: BrowserRouteCache): Promise { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + if (!contentType.includes('application/json')) { + return; + } + + try { + populateCache(await response.clone().json(), cache); + } catch { + // Ignore malformed JSON in routing cache population. + } +} + +function populateCache(value: unknown, cache: BrowserRouteCache): void { + const route = browserRouteFromValue(value); + if (route) { + cache.set(route); + } + + if (Array.isArray(value)) { + for (const item of value) { + populateCache(item, cache); + } + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + for (const child of Object.values(value as Record)) { + if (typeof child === 'object' && child !== null) { + populateCache(child, cache); + } + } +} + +async function routeRequest( + innerFetch: Fetch, + { + input, + init, + request, + }: { + input: RequestInfo; + init: RequestInit | undefined; + request: Request; + }, + apiOrigin: string, + allowed: ReadonlySet, + cache: BrowserRouteCache, +): Promise { + const url = new URL(request.url); + if (url.origin !== apiOrigin) { + return innerFetch(input, init); + } + + const match = url.pathname.match(/^\/(?:v\d+\/)?browsers\/([^/]+)\/([^/]+)(\/.*)?$/); + if (!match) { + return innerFetch(input, init); + } + + const sessionId = decodeURIComponent(match[1] ?? ''); + const subresource = match[2] ?? ''; + if (!sessionId || !allowed.has(subresource)) { + return innerFetch(input, init); + } + const route = cache.get(sessionId); + if (route === undefined) { + return innerFetch(input, init); + } + + const target = new URL(joinURL(route.baseURL, `/${subresource}${match[3] ?? ''}`)); + url.searchParams.forEach((value, key) => { + if (key !== 'jwt') { + target.searchParams.append(key, value); + } + }); + if (!target.searchParams.get('jwt')) { + target.searchParams.set('jwt', route.jwt); + } + + const headers = new Headers(request.headers); + headers.delete('authorization'); + return innerFetch(target.toString(), buildRoutedInit(request, init, headers)); +} + +function buildRoutedInit( + request: Request, + originalInit: RequestInit | undefined, + headers: Headers, +): RequestInit { + const method = request.method.toUpperCase(); + const routedInit = { + ...((originalInit ?? {}) as Record), + method, + headers, + redirect: request.redirect, + signal: request.signal, + } as RequestInit & Record; + + delete routedInit['body']; + delete routedInit['duplex']; + + if (method !== 'GET' && method !== 'HEAD') { + const body = requestBodyForFetch(request, originalInit); + if (body !== undefined) { + routedInit.body = body; + } + if (originalInit?.duplex !== undefined) { + routedInit.duplex = originalInit.duplex; + } else if (requiresHalfDuplex(body)) { + routedInit.duplex = 'half'; + } + } + + return routedInit; +} + +function requestBodyForFetch( + request: Request, + originalInit: RequestInit | undefined, +): RequestInit['body'] | undefined { + if (originalInit?.body !== undefined && originalInit.body !== null) { + return originalInit.body; + } + + return request.body ?? undefined; +} + +function requiresHalfDuplex(body: RequestInit['body'] | undefined): boolean { + return ( + ((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream) || + (typeof body === 'object' && body !== null && Symbol.asyncIterator in body) + ); +} + +function parseJwtFromCdpWsUrl(cdpWsUrl: string | undefined): string | undefined { + if (!cdpWsUrl) { + return undefined; + } + + try { + return new URL(cdpWsUrl).searchParams.get('jwt') ?? undefined; + } catch { + return undefined; + } +} + +function readBrowserRoutingSubresourcesEnv(): string | undefined { + if (typeof (globalThis as any).process !== 'undefined') { + const value = (globalThis as any).process.env?.[BROWSER_ROUTING_SUBRESOURCES_ENV]; + return typeof value === 'string' ? value : undefined; + } + + if (typeof (globalThis as any).Deno !== 'undefined') { + const value = (globalThis as any).Deno.env?.get?.(BROWSER_ROUTING_SUBRESOURCES_ENV); + return typeof value === 'string' ? value : undefined; + } + + return undefined; +} diff --git a/src/lib/join-url.ts b/src/lib/join-url.ts new file mode 100644 index 00000000..7dd54dd6 --- /dev/null +++ b/src/lib/join-url.ts @@ -0,0 +1,3 @@ +export function joinURL(baseURL: string, path: string): string { + return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; +} diff --git a/src/resources/browsers/browsers.ts b/src/resources/browsers/browsers.ts index 61873f31..0cad78e9 100644 --- a/src/resources/browsers/browsers.ts +++ b/src/resources/browsers/browsers.ts @@ -1,5 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +import type { RequestInfo } from '../../internal/builtin-types'; import { APIResource } from '../../core/resource'; import * as Shared from '../shared'; import * as ComputerAPI from './computer'; @@ -75,6 +76,7 @@ import { buildHeaders } from '../../internal/headers'; import { RequestOptions } from '../../internal/request-options'; import { multipartFormRequestOptions } from '../../internal/uploads'; import { path } from '../../internal/utils/path'; +import { browserFetch, type BrowserFetchInit } from '../../lib/browser-fetch'; /** * Create and manage browser sessions. @@ -184,6 +186,14 @@ export class Browsers extends APIResource { return this._client.post(path`/browsers/${id}/curl`, { body, ...options }); } + /** + * Issues an HTTP request through the browser VM network stack, routing directly + * to the browser's `base_url` using the shared browser route cache. + */ + fetch(id: string, input: RequestInfo | URL, init?: BrowserFetchInit): Promise { + return browserFetch(this._client, id, input, init); + } + /** * Delete a browser session by ID * diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts new file mode 100644 index 00000000..1ca285cc --- /dev/null +++ b/tests/lib/browser-routing.test.ts @@ -0,0 +1,395 @@ +import Kernel from '@onkernel/sdk'; + +import { + BrowserRouteCache, + browserRoutingSubresourcesFromEnv, + createRoutingFetch, +} from '../../src/lib/browser-routing'; + +describe('browser routing', () => { + const browserRoutingEnv = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; + + const withBrowserRoutingEnv = async (value: string | undefined, fn: () => Promise) => { + const previous = process.env[browserRoutingEnv]; + if (value === undefined) { + delete process.env[browserRoutingEnv]; + } else { + process.env[browserRoutingEnv] = value; + } + try { + await fn(); + } finally { + if (previous === undefined) { + delete process.env[browserRoutingEnv]; + } else { + process.env[browserRoutingEnv] = previous; + } + } + }; + + const normalizeURL = (input: string | URL | Request) => { + if (typeof input === 'string') { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + return input.url; + }; + + test('warms cache from browser responses and routes allowlisted subresources directly to the VM', async () => { + await withBrowserRoutingEnv('process,curl', async () => { + const calls: Array<{ url: string; headers: Headers }> = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input, init?: RequestInit) => { + const url = normalizeURL(input); + const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers); + calls.push({ url, headers }); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.process.exec('sess-1', { command: 'echo', args: ['hi'] }); + + expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + expect(calls).toHaveLength(2); + expect(calls[1]?.url).toBe('http://browser-session.test/browser/kernel/process/exec?jwt=token-abc'); + expect(calls[1]?.headers.get('authorization')).toBeNull(); + }); + }); + + test('does not route non-allowlisted subresources directly to the VM', async () => { + await withBrowserRoutingEnv('computer', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.process.exec('sess-1', { command: 'echo' }); + + expect(calls[1]).toBe('https://api.example/browsers/sess-1/process/exec'); + }); + }); + + test('withOptions reuses the same browser route cache without double-wrapping fetch', async () => { + await withBrowserRoutingEnv('process', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + await kernel.browsers.create(); + + const child = kernel.withOptions({ timeout: 1234 }); + await child.browsers.process.exec('sess-1', { command: 'echo' }); + + expect(child.browserRouteCache).toBe(kernel.browserRouteCache); + expect(calls).toEqual([ + 'https://api.example/browsers', + 'http://browser-session.test/browser/kernel/process/exec?jwt=token-abc', + ]); + }); + }); + + test('skips cache sniffing for non-browser JSON responses', async () => { + let cloneCalled = false; + const wrappedFetch = createRoutingFetch( + async () => { + const response = Response.json({ ok: true }); + const clone = response.clone.bind(response); + Object.defineProperty(response, 'clone', { + value: () => { + cloneCalled = true; + return clone(); + }, + }); + return response; + }, + { + apiBaseURL: 'https://api.example/', + subresources: ['process'], + cache: new BrowserRouteCache(), + }, + ); + + await wrappedFetch('https://api.example/deployments'); + + expect(cloneCalled).toBe(false); + }); + + test('preserves custom fetch options for both API and routed VM requests', async () => { + await withBrowserRoutingEnv('process', async () => { + const dispatcher = Symbol('dispatcher'); + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetchOptions: { dispatcher } as any, + fetch: async (input, init?: RequestInit) => { + const url = normalizeURL(input); + calls.push({ url, init }); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.process.exec('sess-1', { command: 'echo' }); + + expect((calls[0]?.init as any)?.dispatcher).toBe(dispatcher); + expect((calls[1]?.init as any)?.dispatcher).toBe(dispatcher); + }); + }); + + test('ignores browser responses that do not include a usable jwt', async () => { + await withBrowserRoutingEnv('process', async () => { + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browsers.create(); + expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined(); + }); + }); + + test('browser.fetch uses the shared cache and fails clearly on cache miss', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }); + }, + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + await kernel.browsers.fetch('sess-1', 'https://example.com/hello'); + expect(calls[0]).toContain('http://browser-session.test/browser/kernel/curl/raw?'); + + kernel.browserRouteCache.delete('sess-1'); + await expect(kernel.browsers.fetch('sess-1', 'https://example.com/again')).rejects.toThrow(/route cache/); + }); + + test('warms cache from browser pool acquire responses', async () => { + await withBrowserRoutingEnv('process', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + if (url === 'https://api.example/browser_pools/pool-1/acquire') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browserPools.acquire('pool-1', {}); + await kernel.browsers.process.exec('sess-1', { command: 'echo' }); + + expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + expect(calls).toEqual([ + 'https://api.example/browser_pools/pool-1/acquire', + 'http://browser-session.test/browser/kernel/process/exec?jwt=token-abc', + ]); + }); + }); + + test('evicts cached route after successful browser delete by id', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + return new Response(null, { status: 204 }); + }, + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await kernel.browsers.deleteByID('sess-1'); + + expect(calls).toEqual(['https://api.example/browsers/sess-1']); + expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined(); + }); + + test('evicts cached route after successful browser pool release', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + return new Response(null, { status: 204 }); + }, + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await kernel.browserPools.release('pool-1', { session_id: 'sess-1' }); + + expect(calls).toEqual(['https://api.example/browser_pools/pool-1/release']); + expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined(); + }); + + test('keeps cached route when browser delete by id fails', async () => { + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + maxRetries: 0, + fetch: async () => new Response('boom', { status: 500, headers: { 'content-type': 'text/plain' } }), + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await expect(kernel.browsers.deleteByID('sess-1')).rejects.toThrow(); + expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + }); + + test('keeps cached route when browser pool release fails', async () => { + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + maxRetries: 0, + fetch: async () => new Response('boom', { status: 500, headers: { 'content-type': 'text/plain' } }), + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await expect(kernel.browserPools.release('pool-1', { session_id: 'sess-1' })).rejects.toThrow(); + expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + }); + + test('browser.fetch rejects methods outside the SDK HTTPMethod union', async () => { + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async () => new Response(null, { status: 204 }), + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await expect( + kernel.browsers.fetch('sess-1', 'https://example.com/hello', { method: 'HEAD' }), + ).rejects.toThrow(/unsupported HTTP method/i); + await expect( + kernel.browsers.fetch('sess-1', 'https://example.com/hello', { method: 'OPTIONS' }), + ).rejects.toThrow(/unsupported HTTP method/i); + }); + + test('defaults browser routing subresources to curl when env is unset', async () => { + await withBrowserRoutingEnv(undefined, async () => { + expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl']); + }); + }); + + test('disables browser subresource routing when env is set to empty string', async () => { + await withBrowserRoutingEnv('', async () => { + expect(browserRoutingSubresourcesFromEnv()).toEqual([]); + }); + }); +});