From 94228be699cc42b42caccba4b179875fc6bfcab3 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 11:51:38 -0400 Subject: [PATCH 01/19] feat: add browser-scoped session client Bind browser subresource calls to a browser session's base_url and expose raw HTTP through fetch so metro-routed access feels like normal JavaScript networking. Made-with: Cursor --- examples/browser-scoped.ts | 24 ++ src/client.ts | 10 + src/index.ts | 6 + src/lib/browser-transport.ts | 62 +++ src/lib/kernel-browser-session.ts | 480 +++++++++++++++++++++++ tests/lib/browser-transport.test.ts | 48 +++ tests/lib/kernel-browser-session.test.ts | 80 ++++ 7 files changed, 710 insertions(+) create mode 100644 examples/browser-scoped.ts create mode 100644 src/lib/browser-transport.ts create mode 100644 src/lib/kernel-browser-session.ts create mode 100644 tests/lib/browser-transport.test.ts create mode 100644 tests/lib/kernel-browser-session.test.ts diff --git a/examples/browser-scoped.ts b/examples/browser-scoped.ts new file mode 100644 index 00000000..1ccf5c8d --- /dev/null +++ b/examples/browser-scoped.ts @@ -0,0 +1,24 @@ +/** + * Browser-scoped client: call metro-routed browser APIs without repeating the + * session id, and run `fetch`-style HTTP through the browser network stack. + * + * Run after `yarn build` so `dist/` matches sources, or import from `src/` via + * ts-node with path aliases. + */ +import Kernel from '@onkernel/sdk'; + +async function main() { + const kernel = new Kernel(); + + const created = await kernel.browsers.create({}); + const browser = kernel.forBrowser(created); + + await browser.computer.clickMouse({ x: 10, y: 10 }); + + const page = await browser.fetch('https://example.com', { method: 'GET' }); + console.log('status', page.status); + + await kernel.browsers.deleteByID(created.session_id); +} + +void main(); diff --git a/src/client.ts b/src/client.ts index 7a00e5e5..4039bc74 100644 --- a/src/client.ts +++ b/src/client.ts @@ -67,6 +67,7 @@ import { Deployments, } from './resources/deployments'; import { KernelApp } from './core/app-framework'; +import { KernelBrowserSession, type KernelBrowserInput } from './lib/kernel-browser-session'; import { ExtensionDownloadFromChromeStoreParams, ExtensionListResponse, @@ -881,6 +882,15 @@ export class Kernel { return new KernelApp(name); } + /** + * Returns a browser-scoped client: subresource calls omit the session id and, + * when the browser response includes base_url, requests are routed through the + * metro HTTP base for that session. + */ + public forBrowser(browser: KernelBrowserInput): KernelBrowserSession { + return new KernelBrowserSession(this, browser); + } + static Kernel = this; static DEFAULT_TIMEOUT = 60000; // 1 minute diff --git a/src/index.ts b/src/index.ts index 72d9bc02..eaafa63a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,12 @@ 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 { + KernelBrowserSession, + type BrowserFetchInit, + type KernelBrowserInput, +} from './lib/kernel-browser-session'; +export { type KernelBrowserLike, type ResolvedBrowserTransport } from './lib/browser-transport'; export { PagePromise } from './core/pagination'; export { KernelError, diff --git a/src/lib/browser-transport.ts b/src/lib/browser-transport.ts new file mode 100644 index 00000000..defac41e --- /dev/null +++ b/src/lib/browser-transport.ts @@ -0,0 +1,62 @@ +import type { RequestOptions } from '../internal/request-options'; + +/** + * Resolved HTTP routing for a browser session. Metro requests use defaultBaseURL + * plus a per-request jwt query param. A future client-wide browser-id → base_url + * cache can plug in by supplying an alternate resolver before constructing + * {@link KernelBrowserSession}. + */ +export type ResolvedBrowserTransport = { + sessionId: string; + defaultBaseURL?: string | undefined; + jwt?: string | undefined; +}; + +export type KernelBrowserLike = { + session_id: string; + base_url?: string | null | undefined; + cdp_ws_url?: string | null | undefined; + /** When set, overrides jwt parsed from cdp_ws_url */ + jwt?: string | null | undefined; +}; + +export function parseJwtFromCdpWsUrl(cdpWsUrl: string | null | undefined): string | undefined { + if (!cdpWsUrl) { + return undefined; + } + try { + const u = new URL(cdpWsUrl); + const jwt = u.searchParams.get('jwt'); + return jwt ?? undefined; + } catch { + return undefined; + } +} + +export function resolveBrowserTransport(browser: KernelBrowserLike): ResolvedBrowserTransport { + const sessionId = browser.session_id; + const rawBase = browser.base_url?.trim(); + const defaultBaseURL = rawBase && rawBase.length > 0 ? rawBase : undefined; + const jwt = + (typeof browser.jwt === 'string' && browser.jwt.length > 0 ? browser.jwt : undefined) ?? + parseJwtFromCdpWsUrl(browser.cdp_ws_url ?? undefined); + return { sessionId, defaultBaseURL, jwt }; +} + +export function mergeBrowserScopedRequestOptions( + transport: ResolvedBrowserTransport, + options?: RequestOptions, +): RequestOptions | undefined { + if (!transport.defaultBaseURL) { + return options; + } + const next: RequestOptions = { ...options, defaultBaseURL: transport.defaultBaseURL }; + if (transport.jwt) { + const prev = + options?.query && typeof options.query === 'object' && !Array.isArray(options.query) ? + (options.query as Record) + : {}; + next.query = { ...prev, jwt: transport.jwt }; + } + return next; +} diff --git a/src/lib/kernel-browser-session.ts b/src/lib/kernel-browser-session.ts new file mode 100644 index 00000000..7a8e45a8 --- /dev/null +++ b/src/lib/kernel-browser-session.ts @@ -0,0 +1,480 @@ +import type { HeadersInit, RequestInfo, RequestInit } from '../internal/builtin-types'; +import { Kernel } from '../client'; +import { KernelError } from '../core/error'; +import { APIPromise } from '../core/api-promise'; +import type { RequestOptions } from '../internal/request-options'; +import type { FinalRequestOptions } from '../internal/request-options'; +import type { + BrowserCreateResponse, + BrowserListResponse, + BrowserLoadExtensionsParams, + BrowserRetrieveResponse, +} from '../resources/browsers/browsers'; +import type { + ComputerBatchParams, + ComputerCaptureScreenshotParams, + ComputerClickMouseParams, + ComputerDragMouseParams, + ComputerGetMousePositionResponse, + ComputerMoveMouseParams, + ComputerPressKeyParams, + ComputerReadClipboardResponse, + ComputerScrollParams, + ComputerSetCursorVisibilityParams, + ComputerSetCursorVisibilityResponse, + ComputerTypeTextParams, + ComputerWriteClipboardParams, +} from '../resources/browsers/computer'; +import type { LogStreamParams } from '../resources/browsers/logs'; +import type { PlaywrightExecuteParams, PlaywrightExecuteResponse } from '../resources/browsers/playwright'; +import type { + ProcessExecParams, + ProcessExecResponse, + ProcessKillParams, + ProcessKillResponse, + ProcessResizeParams, + ProcessResizeResponse, + ProcessSpawnParams, + ProcessSpawnResponse, + ProcessStatusResponse, + ProcessStdinParams, + ProcessStdinResponse, + ProcessStdoutStreamResponse, +} from '../resources/browsers/process'; +import type { + ReplayListResponse, + ReplayStartParams, + ReplayStartResponse, +} from '../resources/browsers/replays'; +import type { + FCreateDirectoryParams, + FDeleteDirectoryParams, + FDeleteFileParams, + FDownloadDirZipParams, + FFileInfoParams, + FFileInfoResponse, + FListFilesParams, + FListFilesResponse, + FMoveParams, + FReadFileParams, + FSetFilePermissionsParams, + FUploadParams, + FUploadZipParams, + FWriteFileParams, +} from '../resources/browsers/fs/fs'; +import { Stream } from '../core/streaming'; +import type { LogEvent } from '../resources/shared'; +import { buildHeaders } from '../internal/headers'; +import { + resolveBrowserTransport, + type KernelBrowserLike, + type ResolvedBrowserTransport, +} from './browser-transport'; + +export type KernelBrowserInput = + | KernelBrowserLike + | BrowserCreateResponse + | BrowserRetrieveResponse + | BrowserListResponse; + +export interface BrowserFetchInit extends RequestInit { + /** Passed to the upstream /curl/raw handler as timeout_ms when set. */ + timeout_ms?: number; +} + +/** + * Browser-scoped API view: subresources omit the browser session id, and when + * {@link BrowserCreateResponse.base_url} is present, requests are routed through + * the metro session HTTP base with jwt query authentication. + */ +export class KernelBrowserSession { + readonly sessionId: string; + private readonly kernel: Kernel; + private readonly metro: Kernel; + private readonly transport: ResolvedBrowserTransport; + + constructor(kernel: Kernel, browser: KernelBrowserInput) { + this.kernel = kernel; + this.transport = resolveBrowserTransport(browser); + this.sessionId = this.transport.sessionId; + this.metro = createMetroKernel(kernel, this.transport); + } + + private opt(options?: RequestOptions): RequestOptions | undefined { + return options; + } + + loadExtensions(body: BrowserLoadExtensionsParams, options?: RequestOptions): APIPromise { + return this.metro.browsers.loadExtensions(this.sessionId, body, this.opt(options)); + } + + readonly process = { + exec: (body: ProcessExecParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.process.exec(this.sessionId, body, this.opt(options)); + }, + kill: ( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise => { + return this.metro.browsers.process.kill( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ); + }, + resize: ( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise => { + return this.metro.browsers.process.resize( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ); + }, + spawn: (body: ProcessSpawnParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.process.spawn(this.sessionId, body, this.opt(options)); + }, + status: (processID: string, options?: RequestOptions): APIPromise => { + return this.metro.browsers.process.status(processID, { id: this.sessionId }, this.opt(options)); + }, + stdin: ( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise => { + return this.metro.browsers.process.stdin( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ); + }, + stdoutStream: ( + processID: string, + options?: RequestOptions, + ): APIPromise> => { + return this.metro.browsers.process.stdoutStream(processID, { id: this.sessionId }, this.opt(options)); + }, + }; + + readonly computer = { + batch: (body: ComputerBatchParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.batch(this.sessionId, body, this.opt(options)); + }, + captureScreenshot: ( + body: ComputerCaptureScreenshotParams | null | undefined, + options?: RequestOptions, + ): APIPromise => { + return this.metro.browsers.computer.captureScreenshot(this.sessionId, body ?? {}, this.opt(options)); + }, + clickMouse: (body: ComputerClickMouseParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.clickMouse(this.sessionId, body, this.opt(options)); + }, + dragMouse: (body: ComputerDragMouseParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.dragMouse(this.sessionId, body, this.opt(options)); + }, + getMousePosition: (options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.getMousePosition(this.sessionId, this.opt(options)); + }, + moveMouse: (body: ComputerMoveMouseParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.moveMouse(this.sessionId, body, this.opt(options)); + }, + pressKey: (body: ComputerPressKeyParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.pressKey(this.sessionId, body, this.opt(options)); + }, + readClipboard: (options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.readClipboard(this.sessionId, this.opt(options)); + }, + scroll: (body: ComputerScrollParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.scroll(this.sessionId, body, this.opt(options)); + }, + setCursorVisibility: ( + body: ComputerSetCursorVisibilityParams, + options?: RequestOptions, + ): APIPromise => { + return this.metro.browsers.computer.setCursorVisibility(this.sessionId, body, this.opt(options)); + }, + typeText: (body: ComputerTypeTextParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.typeText(this.sessionId, body, this.opt(options)); + }, + writeClipboard: (body: ComputerWriteClipboardParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.computer.writeClipboard(this.sessionId, body, this.opt(options)); + }, + }; + + readonly logs = { + stream: (query: LogStreamParams, options?: RequestOptions): APIPromise> => { + return this.metro.browsers.logs.stream(this.sessionId, query, this.opt(options)); + }, + }; + + readonly playwright = { + execute: ( + body: PlaywrightExecuteParams, + options?: RequestOptions, + ): APIPromise => { + return this.metro.browsers.playwright.execute(this.sessionId, body, this.opt(options)); + }, + }; + + readonly replays = { + list: (options?: RequestOptions): APIPromise => { + return this.metro.browsers.replays.list(this.sessionId, this.opt(options)); + }, + download: (replayID: string, options?: RequestOptions): APIPromise => { + return this.metro.browsers.replays.download(replayID, { id: this.sessionId }, this.opt(options)); + }, + start: ( + body: ReplayStartParams | null | undefined, + options?: RequestOptions, + ): APIPromise => { + return this.metro.browsers.replays.start(this.sessionId, body ?? {}, this.opt(options)); + }, + stop: (replayID: string, options?: RequestOptions): APIPromise => { + return this.metro.browsers.replays.stop(replayID, { id: this.sessionId }, this.opt(options)); + }, + }; + + readonly fs = { + createDirectory: (body: FCreateDirectoryParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.createDirectory(this.sessionId, body, this.opt(options)); + }, + deleteDirectory: (body: FDeleteDirectoryParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.deleteDirectory(this.sessionId, body, this.opt(options)); + }, + deleteFile: (body: FDeleteFileParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.deleteFile(this.sessionId, body, this.opt(options)); + }, + downloadDirZip: (query: FDownloadDirZipParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.downloadDirZip(this.sessionId, query, this.opt(options)); + }, + fileInfo: (query: FFileInfoParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.fileInfo(this.sessionId, query, this.opt(options)); + }, + listFiles: (query: FListFilesParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.listFiles(this.sessionId, query, this.opt(options)); + }, + move: (body: FMoveParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.move(this.sessionId, body, this.opt(options)); + }, + readFile: (query: FReadFileParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.readFile(this.sessionId, query, this.opt(options)); + }, + setFilePermissions: (body: FSetFilePermissionsParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.setFilePermissions(this.sessionId, body, this.opt(options)); + }, + upload: (body: FUploadParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.upload(this.sessionId, body, this.opt(options)); + }, + uploadZip: (body: FUploadZipParams, options?: RequestOptions): APIPromise => { + return this.metro.browsers.fs.uploadZip(this.sessionId, body, this.opt(options)); + }, + writeFile: ( + contents: string | ArrayBuffer | ArrayBufferView | Blob | DataView, + params: FWriteFileParams, + options?: RequestOptions, + ): APIPromise => { + return this.metro.browsers.fs.writeFile(this.sessionId, contents, params, this.opt(options)); + }, + }; + + /** + * Issue an HTTP request through the browser VM network stack (Chrome), returning + * the upstream response as a standard Fetch {@link Response}. Implemented via + * the session metro {@link BrowserCreateResponse.base_url} and POST /curl/raw. + */ + async fetch(input: RequestInfo | URL, init?: BrowserFetchInit): Promise { + if (!this.transport.defaultBaseURL) { + throw new KernelError( + 'browser.fetch requires browser.base_url from the Kernel API. Create or retrieve the browser and use a response that includes base_url.', + ); + } + if (!this.transport.jwt) { + throw new KernelError( + 'browser.fetch requires a metro session jwt (parsed from cdp_ws_url, or pass jwt on the browser object).', + ); + } + + const { url: targetUrl, method, headers, body, signal, duplex, timeout_ms } = splitFetchArgs(input, init); + assertHttpTargetUrl(targetUrl); + + const query: Record = { + url: targetUrl, + jwt: this.transport.jwt, + }; + if (timeout_ms !== undefined) { + query['timeout_ms'] = timeout_ms; + } + + const accept = headers.get('accept'); + const headerPairs = headersToRequestOptionsHeaders(headers); + + const methodLower = method.toLowerCase(); + const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); + if (!allowed.has(methodLower)) { + throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`); + } + + return this.metro + .request({ + method: methodLower, + path: '/curl/raw', + query, + body: body as RequestOptions['body'], + headers: buildHeaders([accept ? { Accept: accept } : { Accept: '*/*' }, headerPairs]), + signal: signal ?? null, + ...(duplex ? { fetchOptions: { duplex } as RequestOptions['fetchOptions'] } : {}), + __binaryResponse: true, + } as any) + .asResponse(); + } +} + +function createMetroKernel(parent: Kernel, transport: ResolvedBrowserTransport): Kernel { + const defaultQuery = + transport.jwt ? + { + ...(((parent as any)._options?.defaultQuery as Record | undefined) ?? {}), + jwt: transport.jwt, + } + : ((parent as any)._options?.defaultQuery ?? undefined); + + const metro = parent.withOptions({ + baseURL: transport.defaultBaseURL ?? parent.baseURL, + defaultQuery: defaultQuery as Record | undefined, + }) as Kernel; + + const originalPrepareOptions = ((metro as any).prepareOptions as + | ((options: FinalRequestOptions) => Promise) + | undefined)?.bind(metro); + + (metro as any).authHeaders = async () => undefined; + (metro as any).prepareOptions = async (options: FinalRequestOptions) => { + if (originalPrepareOptions) { + await originalPrepareOptions(options); + } + const prefix = `/browsers/${transport.sessionId}/`; + if (options.path.startsWith(prefix)) { + const rest = options.path.slice(prefix.length); + options.path = rest.startsWith('/') ? rest : `/${rest}`; + } + }; + + return metro; +} + +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 merged = new Headers(input.headers); + if (init?.headers) { + const extra = new Headers(init.headers as HeadersInit); + extra.forEach((value, key) => { + merged.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: merged, + }; + const mergedBody = init?.body ?? input.body; + if (mergedBody !== undefined && mergedBody !== null) { + out.body = mergedBody; + } + const mergedSignal = init?.signal ?? input.signal; + if (mergedSignal !== undefined) { + out.signal = mergedSignal; + } + if (init?.duplex !== undefined) { + out.duplex = init.duplex; + } + if (timeoutFromInit !== undefined) { + out.timeout_ms = timeoutFromInit; + } + return out; + } + + const url = input instanceof URL ? input.href : String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + const headers = new Headers(init?.headers as HeadersInit | undefined); + const out: { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; + } = { url, method, 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 assertHttpTargetUrl(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) => { + const lower = key.toLowerCase(); + if ( + lower === 'accept' || + lower === 'content-length' || + lower === 'connection' || + lower === 'keep-alive' || + lower === 'proxy-authenticate' || + lower === 'proxy-authorization' || + lower === 'te' || + lower === 'trailers' || + lower === 'transfer-encoding' || + lower === 'upgrade' + ) { + return; + } + out[key] = value; + }); + return out; +} diff --git a/tests/lib/browser-transport.test.ts b/tests/lib/browser-transport.test.ts new file mode 100644 index 00000000..e81bb235 --- /dev/null +++ b/tests/lib/browser-transport.test.ts @@ -0,0 +1,48 @@ +import { + mergeBrowserScopedRequestOptions, + parseJwtFromCdpWsUrl, + resolveBrowserTransport, +} from '../../src/lib/browser-transport'; + +describe('browser transport', () => { + test('parseJwtFromCdpWsUrl reads jwt query param', () => { + const jwt = parseJwtFromCdpWsUrl('wss://metro.example/browser/cdp?jwt=abc%2B123&x=1'); + expect(jwt).toBe('abc+123'); + }); + + test('resolveBrowserTransport prefers explicit jwt', () => { + const t = resolveBrowserTransport({ + session_id: 'sess', + base_url: 'https://metro/browser/kernel', + cdp_ws_url: 'wss://x/cdp?jwt=fromcdp', + jwt: 'explicit', + }); + expect(t.sessionId).toBe('sess'); + expect(t.defaultBaseURL).toBe('https://metro/browser/kernel'); + expect(t.jwt).toBe('explicit'); + }); + + test('resolveBrowserTransport falls back to cdp_ws_url jwt', () => { + const t = resolveBrowserTransport({ + session_id: 'sess', + base_url: 'https://metro/browser/kernel', + cdp_ws_url: 'wss://x/cdp?jwt=fromcdp', + }); + expect(t.jwt).toBe('fromcdp'); + }); + + test('mergeBrowserScopedRequestOptions injects jwt into query', () => { + const merged = mergeBrowserScopedRequestOptions( + { sessionId: 's', defaultBaseURL: 'https://m/k', jwt: 'j' }, + { query: { a: '1' } }, + ); + expect(merged?.defaultBaseURL).toBe('https://m/k'); + expect(merged?.query).toEqual({ a: '1', jwt: 'j' }); + }); + + test('mergeBrowserScopedRequestOptions is noop without metro base', () => { + const opts = { query: { a: '1' } }; + const merged = mergeBrowserScopedRequestOptions({ sessionId: 's' }, opts); + expect(merged).toBe(opts); + }); +}); diff --git a/tests/lib/kernel-browser-session.test.ts b/tests/lib/kernel-browser-session.test.ts new file mode 100644 index 00000000..9fe5abb4 --- /dev/null +++ b/tests/lib/kernel-browser-session.test.ts @@ -0,0 +1,80 @@ +import Kernel from '@onkernel/sdk'; +import { KernelBrowserSession } from '../../src/lib/kernel-browser-session'; + +describe('KernelBrowserSession.fetch', () => { + test('throws when base_url is missing', async () => { + const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); + const browser = new KernelBrowserSession(kernel, { + session_id: 'abc', + cdp_ws_url: 'wss://x/browser/cdp?jwt=j', + }); + await expect(browser.fetch('https://example.com')).rejects.toThrow(/base_url/); + }); + + test('throws when jwt cannot be resolved', async () => { + const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); + const browser = new KernelBrowserSession(kernel, { + session_id: 'abc', + base_url: 'https://metro/browser/kernel', + cdp_ws_url: 'wss://x/browser/cdp', + }); + await expect(browser.fetch('https://example.com')).rejects.toThrow(/jwt/); + }); + + test('issues /curl/raw against metro base with jwt query', async () => { + const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; + const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); + (kernel as any).fetch = async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, init }); + return new Response('ok', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }); + }; + + const browser = new KernelBrowserSession(kernel, { + session_id: 'abc', + base_url: 'https://metro/browser/kernel', + cdp_ws_url: 'wss://x/browser/cdp?jwt=tok', + }); + + const res = await browser.fetch('https://example.com/hello', { + method: 'GET', + headers: { 'X-Test': '1' }, + }); + expect(res.status).toBe(200); + expect(fetchCalls.length).toBe(1); + const call = fetchCalls[0]!; + expect(call.url).toContain('https://metro/browser/kernel/curl/raw?'); + expect(call.url).toContain('url=https%3A%2F%2Fexample.com%2Fhello'); + expect(call.url).toContain('jwt=tok'); + expect((call.init?.headers as Headers).get('authorization')).toBeNull(); + }); + + test('rewrites browser subresource paths through metro base', async () => { + const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; + const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); + (kernel as any).fetch = async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, init }); + return new Response('', { + status: 200, + headers: { 'content-type': '*/*' }, + }); + }; + + const browser = new KernelBrowserSession(kernel, { + session_id: 'abc', + base_url: 'https://metro/browser/kernel', + cdp_ws_url: 'wss://x/browser/cdp?jwt=tok', + }); + + await browser.computer.clickMouse({ x: 1, y: 2 }); + + expect(fetchCalls.length).toBe(1); + const call = fetchCalls[0]!; + expect(call.url).toContain('https://metro/browser/kernel/computer/click_mouse?'); + expect(call.url).toContain('jwt=tok'); + expect(call.url).not.toContain('/browsers/abc/'); + expect((call.init?.headers as Headers).get('authorization')).toBeNull(); + }); +}); From ae9a739d0b1026b3ebf71d6f7d11ce52070cf42e Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:09:22 -0400 Subject: [PATCH 02/19] fix: require base_url for browser-scoped routing Fail fast when browser-scoped clients do not have a session base_url, route subresource calls through the browser session base directly, and clean up browser-vm wording. Made-with: Cursor --- examples/browser-scoped.ts | 2 +- src/client.ts | 6 +- src/lib/browser-transport.ts | 8 +- src/lib/kernel-browser-session.ts | 127 ++++++++++++----------- src/resources/browser-pools.ts | 2 +- src/resources/browsers/browsers.ts | 8 +- src/resources/invocations.ts | 2 +- tests/lib/browser-transport.test.ts | 10 +- tests/lib/kernel-browser-session.test.ts | 30 +++--- 9 files changed, 101 insertions(+), 94 deletions(-) diff --git a/examples/browser-scoped.ts b/examples/browser-scoped.ts index 1ccf5c8d..33f4c5f7 100644 --- a/examples/browser-scoped.ts +++ b/examples/browser-scoped.ts @@ -1,5 +1,5 @@ /** - * Browser-scoped client: call metro-routed browser APIs without repeating the + * Browser-scoped client: call browser VM-routed browser APIs without repeating the * session id, and run `fetch`-style HTTP through the browser network stack. * * Run after `yarn build` so `dist/` matches sources, or import from `src/` via diff --git a/src/client.ts b/src/client.ts index 4039bc74..ea231c09 100644 --- a/src/client.ts +++ b/src/client.ts @@ -883,9 +883,9 @@ export class Kernel { } /** - * Returns a browser-scoped client: subresource calls omit the session id and, - * when the browser response includes base_url, requests are routed through the - * metro HTTP base for that session. + * Returns a browser-scoped client: subresource calls omit the session id and + * are routed through {@link BrowserCreateResponse.base_url} (browser session + * HTTP base URL for the browser VM edge). Requires base_url on the browser object. */ public forBrowser(browser: KernelBrowserInput): KernelBrowserSession { return new KernelBrowserSession(this, browser); diff --git a/src/lib/browser-transport.ts b/src/lib/browser-transport.ts index defac41e..d355038f 100644 --- a/src/lib/browser-transport.ts +++ b/src/lib/browser-transport.ts @@ -1,10 +1,10 @@ import type { RequestOptions } from '../internal/request-options'; /** - * Resolved HTTP routing for a browser session. Metro requests use defaultBaseURL - * plus a per-request jwt query param. A future client-wide browser-id → base_url - * cache can plug in by supplying an alternate resolver before constructing - * {@link KernelBrowserSession}. + * Resolved HTTP routing for a browser session. When {@link ResolvedBrowserTransport.defaultBaseURL} + * is set, requests use that browser session base URL plus a per-request jwt query param. + * A future client-wide browser-id → base_url cache can plug in by supplying an alternate + * resolver before constructing {@link KernelBrowserSession}. */ export type ResolvedBrowserTransport = { sessionId: string; diff --git a/src/lib/kernel-browser-session.ts b/src/lib/kernel-browser-session.ts index 7a8e45a8..e35fdae0 100644 --- a/src/lib/kernel-browser-session.ts +++ b/src/lib/kernel-browser-session.ts @@ -83,21 +83,28 @@ export interface BrowserFetchInit extends RequestInit { } /** - * Browser-scoped API view: subresources omit the browser session id, and when - * {@link BrowserCreateResponse.base_url} is present, requests are routed through - * the metro session HTTP base with jwt query authentication. + * Browser-scoped API view: subresources omit the browser session id and are routed + * through {@link BrowserCreateResponse.base_url} (browser session HTTP base URL for + * the browser VM edge) with jwt query authentication. */ export class KernelBrowserSession { readonly sessionId: string; - private readonly kernel: Kernel; - private readonly metro: Kernel; + private readonly sessionClient: Kernel; private readonly transport: ResolvedBrowserTransport; constructor(kernel: Kernel, browser: KernelBrowserInput) { - this.kernel = kernel; this.transport = resolveBrowserTransport(browser); this.sessionId = this.transport.sessionId; - this.metro = createMetroKernel(kernel, this.transport); + const baseURL = this.transport.defaultBaseURL; + if (!baseURL) { + throw new KernelError( + 'kernel.forBrowser requires browser.base_url from the Kernel API. Create or retrieve the browser and pass a response that includes base_url before using the browser session client.', + ); + } + this.sessionClient = createBrowserSessionKernel(kernel, { + ...this.transport, + defaultBaseURL: baseURL, + }); } private opt(options?: RequestOptions): RequestOptions | undefined { @@ -105,19 +112,19 @@ export class KernelBrowserSession { } loadExtensions(body: BrowserLoadExtensionsParams, options?: RequestOptions): APIPromise { - return this.metro.browsers.loadExtensions(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.loadExtensions(this.sessionId, body, this.opt(options)); } readonly process = { exec: (body: ProcessExecParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.process.exec(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.process.exec(this.sessionId, body, this.opt(options)); }, kill: ( processID: string, params: Omit, options?: RequestOptions, ): APIPromise => { - return this.metro.browsers.process.kill( + return this.sessionClient.browsers.process.kill( processID, { ...params, id: this.sessionId }, this.opt(options), @@ -128,24 +135,24 @@ export class KernelBrowserSession { params: Omit, options?: RequestOptions, ): APIPromise => { - return this.metro.browsers.process.resize( + return this.sessionClient.browsers.process.resize( processID, { ...params, id: this.sessionId }, this.opt(options), ); }, spawn: (body: ProcessSpawnParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.process.spawn(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.process.spawn(this.sessionId, body, this.opt(options)); }, status: (processID: string, options?: RequestOptions): APIPromise => { - return this.metro.browsers.process.status(processID, { id: this.sessionId }, this.opt(options)); + return this.sessionClient.browsers.process.status(processID, { id: this.sessionId }, this.opt(options)); }, stdin: ( processID: string, params: Omit, options?: RequestOptions, ): APIPromise => { - return this.metro.browsers.process.stdin( + return this.sessionClient.browsers.process.stdin( processID, { ...params, id: this.sessionId }, this.opt(options), @@ -155,58 +162,58 @@ export class KernelBrowserSession { processID: string, options?: RequestOptions, ): APIPromise> => { - return this.metro.browsers.process.stdoutStream(processID, { id: this.sessionId }, this.opt(options)); + return this.sessionClient.browsers.process.stdoutStream(processID, { id: this.sessionId }, this.opt(options)); }, }; readonly computer = { batch: (body: ComputerBatchParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.batch(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.batch(this.sessionId, body, this.opt(options)); }, captureScreenshot: ( body: ComputerCaptureScreenshotParams | null | undefined, options?: RequestOptions, ): APIPromise => { - return this.metro.browsers.computer.captureScreenshot(this.sessionId, body ?? {}, this.opt(options)); + return this.sessionClient.browsers.computer.captureScreenshot(this.sessionId, body ?? {}, this.opt(options)); }, clickMouse: (body: ComputerClickMouseParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.clickMouse(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.clickMouse(this.sessionId, body, this.opt(options)); }, dragMouse: (body: ComputerDragMouseParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.dragMouse(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.dragMouse(this.sessionId, body, this.opt(options)); }, getMousePosition: (options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.getMousePosition(this.sessionId, this.opt(options)); + return this.sessionClient.browsers.computer.getMousePosition(this.sessionId, this.opt(options)); }, moveMouse: (body: ComputerMoveMouseParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.moveMouse(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.moveMouse(this.sessionId, body, this.opt(options)); }, pressKey: (body: ComputerPressKeyParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.pressKey(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.pressKey(this.sessionId, body, this.opt(options)); }, readClipboard: (options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.readClipboard(this.sessionId, this.opt(options)); + return this.sessionClient.browsers.computer.readClipboard(this.sessionId, this.opt(options)); }, scroll: (body: ComputerScrollParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.scroll(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.scroll(this.sessionId, body, this.opt(options)); }, setCursorVisibility: ( body: ComputerSetCursorVisibilityParams, options?: RequestOptions, ): APIPromise => { - return this.metro.browsers.computer.setCursorVisibility(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.setCursorVisibility(this.sessionId, body, this.opt(options)); }, typeText: (body: ComputerTypeTextParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.typeText(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.typeText(this.sessionId, body, this.opt(options)); }, writeClipboard: (body: ComputerWriteClipboardParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.computer.writeClipboard(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.writeClipboard(this.sessionId, body, this.opt(options)); }, }; readonly logs = { stream: (query: LogStreamParams, options?: RequestOptions): APIPromise> => { - return this.metro.browsers.logs.stream(this.sessionId, query, this.opt(options)); + return this.sessionClient.browsers.logs.stream(this.sessionId, query, this.opt(options)); }, }; @@ -215,85 +222,80 @@ export class KernelBrowserSession { body: PlaywrightExecuteParams, options?: RequestOptions, ): APIPromise => { - return this.metro.browsers.playwright.execute(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.playwright.execute(this.sessionId, body, this.opt(options)); }, }; readonly replays = { list: (options?: RequestOptions): APIPromise => { - return this.metro.browsers.replays.list(this.sessionId, this.opt(options)); + return this.sessionClient.browsers.replays.list(this.sessionId, this.opt(options)); }, download: (replayID: string, options?: RequestOptions): APIPromise => { - return this.metro.browsers.replays.download(replayID, { id: this.sessionId }, this.opt(options)); + return this.sessionClient.browsers.replays.download(replayID, { id: this.sessionId }, this.opt(options)); }, start: ( body: ReplayStartParams | null | undefined, options?: RequestOptions, ): APIPromise => { - return this.metro.browsers.replays.start(this.sessionId, body ?? {}, this.opt(options)); + return this.sessionClient.browsers.replays.start(this.sessionId, body ?? {}, this.opt(options)); }, stop: (replayID: string, options?: RequestOptions): APIPromise => { - return this.metro.browsers.replays.stop(replayID, { id: this.sessionId }, this.opt(options)); + return this.sessionClient.browsers.replays.stop(replayID, { id: this.sessionId }, this.opt(options)); }, }; readonly fs = { createDirectory: (body: FCreateDirectoryParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.createDirectory(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.fs.createDirectory(this.sessionId, body, this.opt(options)); }, deleteDirectory: (body: FDeleteDirectoryParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.deleteDirectory(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.fs.deleteDirectory(this.sessionId, body, this.opt(options)); }, deleteFile: (body: FDeleteFileParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.deleteFile(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.fs.deleteFile(this.sessionId, body, this.opt(options)); }, downloadDirZip: (query: FDownloadDirZipParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.downloadDirZip(this.sessionId, query, this.opt(options)); + return this.sessionClient.browsers.fs.downloadDirZip(this.sessionId, query, this.opt(options)); }, fileInfo: (query: FFileInfoParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.fileInfo(this.sessionId, query, this.opt(options)); + return this.sessionClient.browsers.fs.fileInfo(this.sessionId, query, this.opt(options)); }, listFiles: (query: FListFilesParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.listFiles(this.sessionId, query, this.opt(options)); + return this.sessionClient.browsers.fs.listFiles(this.sessionId, query, this.opt(options)); }, move: (body: FMoveParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.move(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.fs.move(this.sessionId, body, this.opt(options)); }, readFile: (query: FReadFileParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.readFile(this.sessionId, query, this.opt(options)); + return this.sessionClient.browsers.fs.readFile(this.sessionId, query, this.opt(options)); }, setFilePermissions: (body: FSetFilePermissionsParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.setFilePermissions(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.fs.setFilePermissions(this.sessionId, body, this.opt(options)); }, upload: (body: FUploadParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.upload(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.fs.upload(this.sessionId, body, this.opt(options)); }, uploadZip: (body: FUploadZipParams, options?: RequestOptions): APIPromise => { - return this.metro.browsers.fs.uploadZip(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.fs.uploadZip(this.sessionId, body, this.opt(options)); }, writeFile: ( contents: string | ArrayBuffer | ArrayBufferView | Blob | DataView, params: FWriteFileParams, options?: RequestOptions, ): APIPromise => { - return this.metro.browsers.fs.writeFile(this.sessionId, contents, params, this.opt(options)); + return this.sessionClient.browsers.fs.writeFile(this.sessionId, contents, params, this.opt(options)); }, }; /** * Issue an HTTP request through the browser VM network stack (Chrome), returning * the upstream response as a standard Fetch {@link Response}. Implemented via - * the session metro {@link BrowserCreateResponse.base_url} and POST /curl/raw. + * the browser session base URL and POST /curl/raw (internal). */ async fetch(input: RequestInfo | URL, init?: BrowserFetchInit): Promise { - if (!this.transport.defaultBaseURL) { - throw new KernelError( - 'browser.fetch requires browser.base_url from the Kernel API. Create or retrieve the browser and use a response that includes base_url.', - ); - } if (!this.transport.jwt) { throw new KernelError( - 'browser.fetch requires a metro session jwt (parsed from cdp_ws_url, or pass jwt on the browser object).', + 'browser.fetch requires a browser session jwt (parsed from cdp_ws_url, or pass jwt on the browser object).', ); } @@ -317,7 +319,7 @@ export class KernelBrowserSession { throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`); } - return this.metro + return this.sessionClient .request({ method: methodLower, path: '/curl/raw', @@ -332,7 +334,10 @@ export class KernelBrowserSession { } } -function createMetroKernel(parent: Kernel, transport: ResolvedBrowserTransport): Kernel { +function createBrowserSessionKernel( + parent: Kernel, + transport: ResolvedBrowserTransport & { defaultBaseURL: string }, +): Kernel { const defaultQuery = transport.jwt ? { @@ -341,17 +346,17 @@ function createMetroKernel(parent: Kernel, transport: ResolvedBrowserTransport): } : ((parent as any)._options?.defaultQuery ?? undefined); - const metro = parent.withOptions({ - baseURL: transport.defaultBaseURL ?? parent.baseURL, + const sessionClient = parent.withOptions({ + baseURL: transport.defaultBaseURL, defaultQuery: defaultQuery as Record | undefined, }) as Kernel; - const originalPrepareOptions = ((metro as any).prepareOptions as + const originalPrepareOptions = ((sessionClient as any).prepareOptions as | ((options: FinalRequestOptions) => Promise) - | undefined)?.bind(metro); + | undefined)?.bind(sessionClient); - (metro as any).authHeaders = async () => undefined; - (metro as any).prepareOptions = async (options: FinalRequestOptions) => { + (sessionClient as any).authHeaders = async () => undefined; + (sessionClient as any).prepareOptions = async (options: FinalRequestOptions) => { if (originalPrepareOptions) { await originalPrepareOptions(options); } @@ -362,7 +367,7 @@ function createMetroKernel(parent: Kernel, transport: ResolvedBrowserTransport): } }; - return metro; + return sessionClient; } function splitFetchArgs( diff --git a/src/resources/browser-pools.ts b/src/resources/browser-pools.ts index f587ded7..76315b2b 100644 --- a/src/resources/browser-pools.ts +++ b/src/resources/browser-pools.ts @@ -306,7 +306,7 @@ export interface BrowserPoolAcquireResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; diff --git a/src/resources/browsers/browsers.ts b/src/resources/browsers/browsers.ts index 61873f31..dc2ff806 100644 --- a/src/resources/browsers/browsers.ts +++ b/src/resources/browsers/browsers.ts @@ -335,7 +335,7 @@ export interface BrowserCreateResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; @@ -441,7 +441,7 @@ export interface BrowserRetrieveResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; @@ -547,7 +547,7 @@ export interface BrowserUpdateResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; @@ -653,7 +653,7 @@ export interface BrowserListResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; diff --git a/src/resources/invocations.ts b/src/resources/invocations.ts index 430751b8..011637c8 100644 --- a/src/resources/invocations.ts +++ b/src/resources/invocations.ts @@ -454,7 +454,7 @@ export namespace InvocationListBrowsersResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; diff --git a/tests/lib/browser-transport.test.ts b/tests/lib/browser-transport.test.ts index e81bb235..2eba5215 100644 --- a/tests/lib/browser-transport.test.ts +++ b/tests/lib/browser-transport.test.ts @@ -6,26 +6,26 @@ import { describe('browser transport', () => { test('parseJwtFromCdpWsUrl reads jwt query param', () => { - const jwt = parseJwtFromCdpWsUrl('wss://metro.example/browser/cdp?jwt=abc%2B123&x=1'); + const jwt = parseJwtFromCdpWsUrl('wss://browser-session.test/browser/cdp?jwt=abc%2B123&x=1'); expect(jwt).toBe('abc+123'); }); test('resolveBrowserTransport prefers explicit jwt', () => { const t = resolveBrowserTransport({ session_id: 'sess', - base_url: 'https://metro/browser/kernel', + base_url: 'https://vm.browser-session.test/browser/kernel', cdp_ws_url: 'wss://x/cdp?jwt=fromcdp', jwt: 'explicit', }); expect(t.sessionId).toBe('sess'); - expect(t.defaultBaseURL).toBe('https://metro/browser/kernel'); + expect(t.defaultBaseURL).toBe('https://vm.browser-session.test/browser/kernel'); expect(t.jwt).toBe('explicit'); }); test('resolveBrowserTransport falls back to cdp_ws_url jwt', () => { const t = resolveBrowserTransport({ session_id: 'sess', - base_url: 'https://metro/browser/kernel', + base_url: 'https://vm.browser-session.test/browser/kernel', cdp_ws_url: 'wss://x/cdp?jwt=fromcdp', }); expect(t.jwt).toBe('fromcdp'); @@ -40,7 +40,7 @@ describe('browser transport', () => { expect(merged?.query).toEqual({ a: '1', jwt: 'j' }); }); - test('mergeBrowserScopedRequestOptions is noop without metro base', () => { + test('mergeBrowserScopedRequestOptions is noop without browser session base URL', () => { const opts = { query: { a: '1' } }; const merged = mergeBrowserScopedRequestOptions({ sessionId: 's' }, opts); expect(merged).toBe(opts); diff --git a/tests/lib/kernel-browser-session.test.ts b/tests/lib/kernel-browser-session.test.ts index 9fe5abb4..5291ab01 100644 --- a/tests/lib/kernel-browser-session.test.ts +++ b/tests/lib/kernel-browser-session.test.ts @@ -1,27 +1,29 @@ import Kernel from '@onkernel/sdk'; import { KernelBrowserSession } from '../../src/lib/kernel-browser-session'; -describe('KernelBrowserSession.fetch', () => { - test('throws when base_url is missing', async () => { +describe('KernelBrowserSession', () => { + test('throws when base_url is missing', () => { const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); - const browser = new KernelBrowserSession(kernel, { - session_id: 'abc', - cdp_ws_url: 'wss://x/browser/cdp?jwt=j', - }); - await expect(browser.fetch('https://example.com')).rejects.toThrow(/base_url/); + expect( + () => + new KernelBrowserSession(kernel, { + session_id: 'abc', + cdp_ws_url: 'wss://x/browser/cdp?jwt=j', + }), + ).toThrow(/base_url/); }); test('throws when jwt cannot be resolved', async () => { const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); const browser = new KernelBrowserSession(kernel, { session_id: 'abc', - base_url: 'https://metro/browser/kernel', + base_url: 'https://vm.browser-session.test/browser/kernel', cdp_ws_url: 'wss://x/browser/cdp', }); await expect(browser.fetch('https://example.com')).rejects.toThrow(/jwt/); }); - test('issues /curl/raw against metro base with jwt query', async () => { + test('issues /curl/raw against browser session base URL with jwt query', async () => { const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); (kernel as any).fetch = async (url: string, init?: RequestInit) => { @@ -34,7 +36,7 @@ describe('KernelBrowserSession.fetch', () => { const browser = new KernelBrowserSession(kernel, { session_id: 'abc', - base_url: 'https://metro/browser/kernel', + base_url: 'https://vm.browser-session.test/browser/kernel', cdp_ws_url: 'wss://x/browser/cdp?jwt=tok', }); @@ -45,13 +47,13 @@ describe('KernelBrowserSession.fetch', () => { expect(res.status).toBe(200); expect(fetchCalls.length).toBe(1); const call = fetchCalls[0]!; - expect(call.url).toContain('https://metro/browser/kernel/curl/raw?'); + expect(call.url).toContain('https://vm.browser-session.test/browser/kernel/curl/raw?'); expect(call.url).toContain('url=https%3A%2F%2Fexample.com%2Fhello'); expect(call.url).toContain('jwt=tok'); expect((call.init?.headers as Headers).get('authorization')).toBeNull(); }); - test('rewrites browser subresource paths through metro base', async () => { + test('rewrites browser subresource paths through browser session base URL', async () => { const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); (kernel as any).fetch = async (url: string, init?: RequestInit) => { @@ -64,7 +66,7 @@ describe('KernelBrowserSession.fetch', () => { const browser = new KernelBrowserSession(kernel, { session_id: 'abc', - base_url: 'https://metro/browser/kernel', + base_url: 'https://vm.browser-session.test/browser/kernel', cdp_ws_url: 'wss://x/browser/cdp?jwt=tok', }); @@ -72,7 +74,7 @@ describe('KernelBrowserSession.fetch', () => { expect(fetchCalls.length).toBe(1); const call = fetchCalls[0]!; - expect(call.url).toContain('https://metro/browser/kernel/computer/click_mouse?'); + expect(call.url).toContain('https://vm.browser-session.test/browser/kernel/computer/click_mouse?'); expect(call.url).toContain('jwt=tok'); expect(call.url).not.toContain('/browsers/abc/'); expect((call.init?.headers as Headers).get('authorization')).toBeNull(); From d835f6925b8940bbf58703ddbb03858117f0e070 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:19:45 -0400 Subject: [PATCH 03/19] fix: enforce browser base_url routing Fail fast when browser-scoped clients are missing a browser session base_url, route subresource calls through the session base consistently, and keep lint output clean. Made-with: Cursor --- src/lib/kernel-browser-session.ts | 32 +++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/lib/kernel-browser-session.ts b/src/lib/kernel-browser-session.ts index e35fdae0..45f33205 100644 --- a/src/lib/kernel-browser-session.ts +++ b/src/lib/kernel-browser-session.ts @@ -162,7 +162,11 @@ export class KernelBrowserSession { processID: string, options?: RequestOptions, ): APIPromise> => { - return this.sessionClient.browsers.process.stdoutStream(processID, { id: this.sessionId }, this.opt(options)); + return this.sessionClient.browsers.process.stdoutStream( + processID, + { id: this.sessionId }, + this.opt(options), + ); }, }; @@ -174,7 +178,11 @@ export class KernelBrowserSession { body: ComputerCaptureScreenshotParams | null | undefined, options?: RequestOptions, ): APIPromise => { - return this.sessionClient.browsers.computer.captureScreenshot(this.sessionId, body ?? {}, this.opt(options)); + return this.sessionClient.browsers.computer.captureScreenshot( + this.sessionId, + body ?? {}, + this.opt(options), + ); }, clickMouse: (body: ComputerClickMouseParams, options?: RequestOptions): APIPromise => { return this.sessionClient.browsers.computer.clickMouse(this.sessionId, body, this.opt(options)); @@ -201,7 +209,11 @@ export class KernelBrowserSession { body: ComputerSetCursorVisibilityParams, options?: RequestOptions, ): APIPromise => { - return this.sessionClient.browsers.computer.setCursorVisibility(this.sessionId, body, this.opt(options)); + return this.sessionClient.browsers.computer.setCursorVisibility( + this.sessionId, + body, + this.opt(options), + ); }, typeText: (body: ComputerTypeTextParams, options?: RequestOptions): APIPromise => { return this.sessionClient.browsers.computer.typeText(this.sessionId, body, this.opt(options)); @@ -231,7 +243,11 @@ export class KernelBrowserSession { return this.sessionClient.browsers.replays.list(this.sessionId, this.opt(options)); }, download: (replayID: string, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.replays.download(replayID, { id: this.sessionId }, this.opt(options)); + return this.sessionClient.browsers.replays.download( + replayID, + { id: this.sessionId }, + this.opt(options), + ); }, start: ( body: ReplayStartParams | null | undefined, @@ -344,16 +360,16 @@ function createBrowserSessionKernel( ...(((parent as any)._options?.defaultQuery as Record | undefined) ?? {}), jwt: transport.jwt, } - : ((parent as any)._options?.defaultQuery ?? undefined); + : (parent as any)._options?.defaultQuery ?? undefined; const sessionClient = parent.withOptions({ baseURL: transport.defaultBaseURL, defaultQuery: defaultQuery as Record | undefined, }) as Kernel; - const originalPrepareOptions = ((sessionClient as any).prepareOptions as - | ((options: FinalRequestOptions) => Promise) - | undefined)?.bind(sessionClient); + const originalPrepareOptions = ( + (sessionClient as any).prepareOptions as ((options: FinalRequestOptions) => Promise) | undefined + )?.bind(sessionClient); (sessionClient as any).authHeaders = async () => undefined; (sessionClient as any).prepareOptions = async (options: FinalRequestOptions) => { From e730af816f72663041170b4602c009ae5e86ba9c Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 18:27:02 -0400 Subject: [PATCH 04/19] feat: generate browser-scoped session bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the handwritten Node browser-scoped façade with deterministic generated bindings from the browser resource graph, and enforce regeneration during lint and build. Made-with: Cursor --- package.json | 2 + scripts/build | 3 + scripts/generate-browser-session.ts | 332 ++++++++++++++++++ scripts/lint | 7 + src/lib/generated/browser-session-bindings.ts | 326 +++++++++++++++++ src/lib/kernel-browser-session.ts | 275 +-------------- yarn.lock | 66 +++- 7 files changed, 748 insertions(+), 263 deletions(-) create mode 100644 scripts/generate-browser-session.ts create mode 100644 src/lib/generated/browser-session-bindings.ts diff --git a/package.json b/package.json index 7eabcc83..08896410 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "prepublishOnly": "echo 'to publish, run yarn build && (cd dist; yarn publish)' && exit 1", "format": "./scripts/format", "prepare": "if ./scripts/utils/check-is-in-git-install.sh; then ./scripts/build && ./scripts/utils/git-swap.sh; fi", + "generate:browser-session": "ts-node -T scripts/generate-browser-session.ts", "tsn": "ts-node -r tsconfig-paths/register", "lint": "./scripts/lint", "fix": "./scripts/format" @@ -45,6 +46,7 @@ "prettier": "^3.0.0", "publint": "^0.2.12", "ts-jest": "^29.1.0", + "ts-morph": "^28.0.0", "ts-node": "^10.5.0", "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", diff --git a/scripts/build b/scripts/build index a008cb00..8d747f2f 100755 --- a/scripts/build +++ b/scripts/build @@ -6,6 +6,9 @@ cd "$(dirname "$0")/.." node scripts/utils/check-version.cjs +./node_modules/.bin/ts-node -T scripts/generate-browser-session.ts +./node_modules/.bin/prettier --write src/lib/generated/browser-session-bindings.ts >/dev/null + # Build into dist and will publish the package from there, # so that src/resources/foo.ts becomes /resources/foo.js # This way importing from `"@onkernel/sdk/resources/foo"` works diff --git a/scripts/generate-browser-session.ts b/scripts/generate-browser-session.ts new file mode 100644 index 00000000..4369163f --- /dev/null +++ b/scripts/generate-browser-session.ts @@ -0,0 +1,332 @@ +#!/usr/bin/env -S node + +import fs from 'fs'; +import path from 'path'; +import { + Node, + Project, + SyntaxKind, + type MethodDeclaration, + type ParameterDeclaration, + type PropertyDeclaration, +} from 'ts-morph'; + +type ChildMeta = { + propName: string; + targetClass: string; +}; + +type MethodMeta = { + name: string; + signature: string; + returnType: string; + implArgs: string; +}; + +type ResourceMeta = { + className: string; + filePath: string; + importPath: string; + alias: string; + exportedTypeNames: Set; + children: ChildMeta[]; + methods: MethodMeta[]; +}; + +const repoRoot = path.resolve(__dirname, '..'); +const resourcesDir = path.join(repoRoot, 'src', 'resources', 'browsers'); +const outputFile = path.join(repoRoot, 'src', 'lib', 'generated', 'browser-session-bindings.ts'); + +const project = new Project({ + skipAddingFilesFromTsConfig: true, +}); + +project.addSourceFilesAtPaths(path.join(resourcesDir, '**/*.ts')); + +const sourceFiles = project + .getSourceFiles() + .filter((sf) => !sf.getBaseName().endsWith('.test.ts') && sf.getBaseName() !== 'index.ts'); + +const resourceByClass = new Map(); + +for (const sf of sourceFiles) { + for (const classDecl of sf.getClasses()) { + if (classDecl.getName() == null) continue; + if (!extendsAPIResource(classDecl)) continue; + + const className = classDecl.getNameOrThrow(); + const importPath = toImportPath(path.relative(path.dirname(outputFile), sf.getFilePath())); + const alias = `${className}API`; + const exportedTypeNames = new Set( + sf + .getStatements() + .filter( + (stmt) => + Node.isInterfaceDeclaration(stmt) || + Node.isTypeAliasDeclaration(stmt) || + Node.isEnumDeclaration(stmt), + ) + .map((stmt) => { + if ( + Node.isInterfaceDeclaration(stmt) || + Node.isTypeAliasDeclaration(stmt) || + Node.isEnumDeclaration(stmt) + ) { + return stmt.getName(); + } + return ''; + }) + .filter(Boolean), + ); + + const meta: ResourceMeta = { + className, + filePath: sf.getFilePath(), + importPath, + alias, + exportedTypeNames, + children: extractChildren(classDecl), + methods: [], + }; + resourceByClass.set(className, meta); + } +} + +for (const sf of sourceFiles) { + for (const classDecl of sf.getClasses()) { + const className = classDecl.getName(); + if (!className) continue; + const meta = resourceByClass.get(className); + if (!meta) continue; + + for (const method of classDecl.getMethods()) { + const parsed = parseMethod(meta, method); + if (parsed) meta.methods.push(parsed); + } + } +} + +const browserMeta = resourceByClass.get('Browsers'); +if (!browserMeta) { + throw new Error('Could not find Browsers resource'); +} + +const ordered = orderResources(browserMeta, resourceByClass); +const importLines = ordered + .map((meta) => `import type * as ${meta.alias} from '${meta.importPath}';`) + .join('\n'); + +const interfaces = ordered.map((meta) => emitInterface(meta, resourceByClass)).join('\n\n'); + +const constructorAssignments = browserMeta.children + .map((child) => emitAssignment(child, resourceByClass)) + .join('\n '); + +const rootMethods = browserMeta.methods.map((method) => emitMethod(method, 'browsers')).join('\n\n'); + +const output = `// This file is generated by scripts/generate-browser-session.ts.\n// Do not edit by hand.\n\nimport type { Kernel } from '../../client';\nimport type { APIPromise } from '../../core/api-promise';\nimport type { RequestOptions } from '../../internal/request-options';\nimport type { Stream } from '../../core/streaming';\nimport type * as Shared from '../../resources/shared';\n${importLines}\n\n${interfaces}\n\nexport class GeneratedBrowserSessionBindings {\n protected readonly sessionClient: Kernel;\n readonly sessionId: string;\n\n readonly ${browserMeta.children + .map((child) => `${child.propName}: ${bindingName(child.targetClass)}`) + .join( + '\n readonly ', + )};\n\n constructor(sessionClient: Kernel, sessionId: string) {\n this.sessionClient = sessionClient;\n this.sessionId = sessionId;\n ${constructorAssignments}\n }\n\n protected opt(options?: RequestOptions): RequestOptions | undefined {\n return options;\n }\n\n${indent( + rootMethods, + 2, +)}\n}\n`; + +fs.mkdirSync(path.dirname(outputFile), { recursive: true }); +fs.writeFileSync(outputFile, output); + +function extendsAPIResource(classDecl: import('ts-morph').ClassDeclaration): boolean { + const ext = classDecl.getExtends(); + if (!ext) return false; + const text = ext.getExpression().getText(); + return text === 'APIResource'; +} + +function extractChildren(classDecl: import('ts-morph').ClassDeclaration): ChildMeta[] { + return classDecl + .getProperties() + .filter((prop) => !prop.getName().startsWith('with_')) + .map((prop) => { + const targetClass = childClassName(prop); + if (!targetClass) return null; + return { propName: prop.getName(), targetClass }; + }) + .filter((v): v is ChildMeta => v !== null); +} + +function childClassName(prop: PropertyDeclaration): string | null { + const init = prop.getInitializer(); + if (!init || !Node.isNewExpression(init)) return null; + const expr = init.getExpression().getText(); + const last = expr.split('.').pop() || expr; + return last; +} + +function parseMethod(meta: ResourceMeta, method: MethodDeclaration): MethodMeta | null { + if (!isPublicMethod(method)) return null; + const pathText = getPathTemplate(method); + if (!pathText) return null; + if (meta.className === 'Browsers' && !pathText.includes('/browsers/${id}/')) return null; + + const params = method.getParameters(); + const idParam = params[0]?.getName() === 'id' ? params[0] : undefined; + const paramsIdName = detectParamsIdParam(method); + if (!idParam && !paramsIdName) return null; + + const publicParams = params + .filter((param) => param !== idParam) + .map((param) => formatParam(meta, param, paramsIdName, true)) + .join(', '); + + const implArgs = params + .map((param) => { + const name = param.getName(); + if (param === idParam) return 'this.sessionId'; + if (paramsIdName && name === paramsIdName) return `{ ...${name}, id: this.sessionId }`; + if (name === 'options') return 'this.opt(options)'; + return name; + }) + .join(', '); + + return { + name: method.getName(), + signature: publicParams, + returnType: rewriteType(meta, method.getReturnTypeNodeOrThrow().getText()), + implArgs, + }; +} + +function isPublicMethod(method: MethodDeclaration): boolean { + const name = method.getName(); + if (name.startsWith('_')) return false; + if (method.isStatic()) return false; + return true; +} + +function getPathTemplate(method: MethodDeclaration): string | null { + const tag = method + .getDescendantsOfKind(SyntaxKind.TaggedTemplateExpression) + .find((node) => node.getTag().getText() === 'path'); + return tag?.getTemplate().getText() ?? null; +} + +function detectParamsIdParam(method: MethodDeclaration): string | null { + const body = method.getBodyText() ?? ''; + const match = body.match(/const\s+\{\s*id(?:\s*,\s*\.\.\.\w+)?\s*\}\s*=\s*(\w+)/); + return match?.[1] ?? null; +} + +function formatParam( + meta: ResourceMeta, + param: ParameterDeclaration, + paramsIdName: string | null, + includeInitializer: boolean, +): string { + const name = param.getName(); + let typeText = param.getTypeNodeOrThrow().getText(); + typeText = rewriteType(meta, typeText); + if (paramsIdName && name === paramsIdName) { + typeText = `Omit<${typeText}, 'id'>`; + } + const initializer = includeInitializer ? param.getInitializer()?.getText() : undefined; + const question = param.hasQuestionToken() ? '?' : ''; + return `${name}${question}: ${typeText}${initializer ? ` = ${initializer}` : ''}`; +} + +function rewriteType(meta: ResourceMeta, text: string): string { + let out = text; + const typeNames = Array.from(meta.exportedTypeNames).sort((a, b) => b.length - a.length); + for (const name of typeNames) { + out = out.replace(new RegExp(`\\b${name}\\b`, 'g'), `${meta.alias}.${name}`); + } + return out; +} + +function orderResources(root: ResourceMeta, all: Map): ResourceMeta[] { + const out: ResourceMeta[] = []; + const seen = new Set(); + const visit = (meta: ResourceMeta) => { + if (seen.has(meta.className)) return; + seen.add(meta.className); + for (const child of meta.children) { + const childMeta = all.get(child.targetClass); + if (childMeta) visit(childMeta); + } + out.push(meta); + }; + visit(root); + return out.filter((meta) => meta.className !== 'Browsers').concat(root); +} + +function emitInterface(meta: ResourceMeta, all: Map): string { + const lines: string[] = []; + for (const method of meta.methods) { + const noInitSignature = method.signature.replace(/\s*=\s*[^,)+]+/g, ''); + lines.push(` ${method.name}(${noInitSignature}): ${method.returnType};`); + } + for (const child of meta.children) { + if (all.has(child.targetClass)) { + lines.push(` ${child.propName}: ${bindingName(child.targetClass)};`); + } + } + return `export interface ${bindingName(meta.className)} {\n${lines.join('\n')}\n}`; +} + +function bindingName(className: string): string { + return `BrowserSession${className}Bindings`; +} + +function emitAssignment(child: ChildMeta, all: Map): string { + const meta = all.get(child.targetClass)!; + const methodLines = meta.methods.map((method) => { + return `${method.name}: (${method.signature}) => this.sessionClient.browsers.${resourceCallPath( + meta.filePath, + )}.${method.name}(${method.implArgs}),`; + }); + const childLines = meta.children.map((nested) => emitNestedObject(nested, all)); + return `this.${child.propName} = {\n ${[...methodLines, ...childLines].join('\n ')}\n };`; +} + +function emitNestedObject(child: ChildMeta, all: Map): string { + const meta = all.get(child.targetClass)!; + const methodLines = meta.methods.map((method) => { + return `${method.name}: (${method.signature}) => this.sessionClient.browsers.${resourceCallPath( + meta.filePath, + )}.${method.name}(${method.implArgs}),`; + }); + const nestedLines = meta.children.map((nested) => emitNestedObject(nested, all)); + return `${child.propName}: {\n ${[...methodLines, ...nestedLines].join('\n ')}\n },`; +} + +function resourceCallPath(filePath: string): string { + const rel = path.relative(resourcesDir, filePath).replace(/\\/g, '/').replace(/\.ts$/, ''); + const segments = rel.split('/'); + if (segments[segments.length - 1] === 'fs') { + return 'fs'; + } + if (segments[0] === 'fs') { + return ['fs', ...segments.slice(1)].join('.'); + } + if (segments[0] === 'browsers') { + return segments.slice(1).join('.'); + } + return segments.join('.'); +} + +function emitMethod(method: MethodMeta, resourcePrefix: string): string { + return `${method.name}(${method.signature}): ${method.returnType} {\n return this.sessionClient.${resourcePrefix}.${method.name}(${method.implArgs});\n }`; +} + +function toImportPath(relPath: string): string { + const normalized = relPath.replace(/\\/g, '/').replace(/\.ts$/, ''); + return normalized.startsWith('.') ? normalized : `./${normalized}`; +} + +function indent(value: string, depth: number): string { + const prefix = ' '.repeat(depth); + return value + .split('\n') + .map((line) => (line.length ? `${prefix}${line}` : line)) + .join('\n'); +} diff --git a/scripts/lint b/scripts/lint index 3ffb78a6..caaa979b 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,6 +4,13 @@ set -e cd "$(dirname "$0")/.." +echo "==> Regenerating browser session bindings" +./node_modules/.bin/ts-node -T scripts/generate-browser-session.ts +./node_modules/.bin/prettier --write src/lib/generated/browser-session-bindings.ts >/dev/null + +echo "==> Verifying generated browser session bindings are committed" +git diff --exit-code -- src/lib/generated/browser-session-bindings.ts + echo "==> Running eslint" ./node_modules/.bin/eslint . diff --git a/src/lib/generated/browser-session-bindings.ts b/src/lib/generated/browser-session-bindings.ts new file mode 100644 index 00000000..4190c103 --- /dev/null +++ b/src/lib/generated/browser-session-bindings.ts @@ -0,0 +1,326 @@ +// This file is generated by scripts/generate-browser-session.ts. +// Do not edit by hand. + +import type { Kernel } from '../../client'; +import type { APIPromise } from '../../core/api-promise'; +import type { RequestOptions } from '../../internal/request-options'; +import type { Stream } from '../../core/streaming'; +import type * as Shared from '../../resources/shared'; +import type * as ReplaysAPI from '../../resources/browsers/replays'; +import type * as WatchAPI from '../../resources/browsers/fs/watch'; +import type * as FsAPI from '../../resources/browsers/fs/fs'; +import type * as ProcessAPI from '../../resources/browsers/process'; +import type * as LogsAPI from '../../resources/browsers/logs'; +import type * as ComputerAPI from '../../resources/browsers/computer'; +import type * as PlaywrightAPI from '../../resources/browsers/playwright'; +import type * as BrowsersAPI from '../../resources/browsers/browsers'; + +export interface BrowserSessionReplaysBindings { + list(options?: RequestOptions): APIPromise; + download( + replayID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + start( + body: ReplaysAPI.ReplayStartParams | null | undefined, + options?: RequestOptions, + ): APIPromise; + stop( + replayID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; +} + +export interface BrowserSessionWatchBindings { + events( + watchID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise>; + start(body: WatchAPI.WatchStartParams, options?: RequestOptions): APIPromise; + stop( + watchID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; +} + +export interface BrowserSessionFsBindings { + createDirectory(body: FsAPI.FCreateDirectoryParams, options?: RequestOptions): APIPromise; + deleteDirectory(body: FsAPI.FDeleteDirectoryParams, options?: RequestOptions): APIPromise; + deleteFile(body: FsAPI.FDeleteFileParams, options?: RequestOptions): APIPromise; + downloadDirZip(query: FsAPI.FDownloadDirZipParams, options?: RequestOptions): APIPromise; + fileInfo(query: FsAPI.FFileInfoParams, options?: RequestOptions): APIPromise; + listFiles(query: FsAPI.FListFilesParams, options?: RequestOptions): APIPromise; + move(body: FsAPI.FMoveParams, options?: RequestOptions): APIPromise; + readFile(query: FsAPI.FReadFileParams, options?: RequestOptions): APIPromise; + setFilePermissions(body: FsAPI.FSetFilePermissionsParams, options?: RequestOptions): APIPromise; + upload(body: FsAPI.FUploadParams, options?: RequestOptions): APIPromise; + uploadZip(body: FsAPI.FUploadZipParams, options?: RequestOptions): APIPromise; + writeFile( + contents: string | ArrayBuffer | ArrayBufferView | Blob | DataView, + params: FsAPI.FWriteFileParams, + options?: RequestOptions, + ): APIPromise; + watch: BrowserSessionWatchBindings; +} + +export interface BrowserSessionProcessBindings { + exec( + body: ProcessAPI.ProcessExecParams, + options?: RequestOptions, + ): APIPromise; + kill( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + resize( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + spawn( + body: ProcessAPI.ProcessSpawnParams, + options?: RequestOptions, + ): APIPromise; + status( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + stdin( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + stdoutStream( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise>; +} + +export interface BrowserSessionLogsBindings { + stream(query: LogsAPI.LogStreamParams, options?: RequestOptions): APIPromise>; +} + +export interface BrowserSessionComputerBindings { + batch(body: ComputerAPI.ComputerBatchParams, options?: RequestOptions): APIPromise; + captureScreenshot( + body: ComputerAPI.ComputerCaptureScreenshotParams | null | undefined, + options?: RequestOptions, + ): APIPromise; + clickMouse(body: ComputerAPI.ComputerClickMouseParams, options?: RequestOptions): APIPromise; + dragMouse(body: ComputerAPI.ComputerDragMouseParams, options?: RequestOptions): APIPromise; + getMousePosition(options?: RequestOptions): APIPromise; + moveMouse(body: ComputerAPI.ComputerMoveMouseParams, options?: RequestOptions): APIPromise; + pressKey(body: ComputerAPI.ComputerPressKeyParams, options?: RequestOptions): APIPromise; + readClipboard(options?: RequestOptions): APIPromise; + scroll(body: ComputerAPI.ComputerScrollParams, options?: RequestOptions): APIPromise; + setCursorVisibility( + body: ComputerAPI.ComputerSetCursorVisibilityParams, + options?: RequestOptions, + ): APIPromise; + typeText(body: ComputerAPI.ComputerTypeTextParams, options?: RequestOptions): APIPromise; + writeClipboard(body: ComputerAPI.ComputerWriteClipboardParams, options?: RequestOptions): APIPromise; +} + +export interface BrowserSessionPlaywrightBindings { + execute( + body: PlaywrightAPI.PlaywrightExecuteParams, + options?: RequestOptions, + ): APIPromise; +} + +export interface BrowserSessionBrowsersBindings { + loadExtensions(body: BrowsersAPI.BrowserLoadExtensionsParams, options?: RequestOptions): APIPromise; + replays: BrowserSessionReplaysBindings; + fs: BrowserSessionFsBindings; + process: BrowserSessionProcessBindings; + logs: BrowserSessionLogsBindings; + computer: BrowserSessionComputerBindings; + playwright: BrowserSessionPlaywrightBindings; +} + +export class GeneratedBrowserSessionBindings { + protected readonly sessionClient: Kernel; + readonly sessionId: string; + + readonly replays: BrowserSessionReplaysBindings; + readonly fs: BrowserSessionFsBindings; + readonly process: BrowserSessionProcessBindings; + readonly logs: BrowserSessionLogsBindings; + readonly computer: BrowserSessionComputerBindings; + readonly playwright: BrowserSessionPlaywrightBindings; + + constructor(sessionClient: Kernel, sessionId: string) { + this.sessionClient = sessionClient; + this.sessionId = sessionId; + this.replays = { + list: (options?: RequestOptions) => + this.sessionClient.browsers.replays.list(this.sessionId, this.opt(options)), + download: ( + replayID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.replays.download( + replayID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + start: (body: ReplaysAPI.ReplayStartParams | null | undefined = {}, options?: RequestOptions) => + this.sessionClient.browsers.replays.start(this.sessionId, body, this.opt(options)), + stop: (replayID: string, params: Omit, options?: RequestOptions) => + this.sessionClient.browsers.replays.stop( + replayID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + }; + this.fs = { + createDirectory: (body: FsAPI.FCreateDirectoryParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.createDirectory(this.sessionId, body, this.opt(options)), + deleteDirectory: (body: FsAPI.FDeleteDirectoryParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.deleteDirectory(this.sessionId, body, this.opt(options)), + deleteFile: (body: FsAPI.FDeleteFileParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.deleteFile(this.sessionId, body, this.opt(options)), + downloadDirZip: (query: FsAPI.FDownloadDirZipParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.downloadDirZip(this.sessionId, query, this.opt(options)), + fileInfo: (query: FsAPI.FFileInfoParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.fileInfo(this.sessionId, query, this.opt(options)), + listFiles: (query: FsAPI.FListFilesParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.listFiles(this.sessionId, query, this.opt(options)), + move: (body: FsAPI.FMoveParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.move(this.sessionId, body, this.opt(options)), + readFile: (query: FsAPI.FReadFileParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.readFile(this.sessionId, query, this.opt(options)), + setFilePermissions: (body: FsAPI.FSetFilePermissionsParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.setFilePermissions(this.sessionId, body, this.opt(options)), + upload: (body: FsAPI.FUploadParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.upload(this.sessionId, body, this.opt(options)), + uploadZip: (body: FsAPI.FUploadZipParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.uploadZip(this.sessionId, body, this.opt(options)), + writeFile: ( + contents: string | ArrayBuffer | ArrayBufferView | Blob | DataView, + params: FsAPI.FWriteFileParams, + options?: RequestOptions, + ) => this.sessionClient.browsers.fs.writeFile(this.sessionId, contents, params, this.opt(options)), + watch: { + events: (watchID: string, params: Omit, options?: RequestOptions) => + this.sessionClient.browsers.fs.watch.events( + watchID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + start: (body: WatchAPI.WatchStartParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.watch.start(this.sessionId, body, this.opt(options)), + stop: (watchID: string, params: Omit, options?: RequestOptions) => + this.sessionClient.browsers.fs.watch.stop( + watchID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + }, + }; + this.process = { + exec: (body: ProcessAPI.ProcessExecParams, options?: RequestOptions) => + this.sessionClient.browsers.process.exec(this.sessionId, body, this.opt(options)), + kill: (processID: string, params: Omit, options?: RequestOptions) => + this.sessionClient.browsers.process.kill( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + resize: ( + processID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.process.resize( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + spawn: (body: ProcessAPI.ProcessSpawnParams, options?: RequestOptions) => + this.sessionClient.browsers.process.spawn(this.sessionId, body, this.opt(options)), + status: ( + processID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.process.status( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + stdin: ( + processID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.process.stdin( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + stdoutStream: ( + processID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.process.stdoutStream( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + }; + this.logs = { + stream: (query: LogsAPI.LogStreamParams, options?: RequestOptions) => + this.sessionClient.browsers.logs.stream(this.sessionId, query, this.opt(options)), + }; + this.computer = { + batch: (body: ComputerAPI.ComputerBatchParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.batch(this.sessionId, body, this.opt(options)), + captureScreenshot: ( + body: ComputerAPI.ComputerCaptureScreenshotParams | null | undefined = {}, + options?: RequestOptions, + ) => this.sessionClient.browsers.computer.captureScreenshot(this.sessionId, body, this.opt(options)), + clickMouse: (body: ComputerAPI.ComputerClickMouseParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.clickMouse(this.sessionId, body, this.opt(options)), + dragMouse: (body: ComputerAPI.ComputerDragMouseParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.dragMouse(this.sessionId, body, this.opt(options)), + getMousePosition: (options?: RequestOptions) => + this.sessionClient.browsers.computer.getMousePosition(this.sessionId, this.opt(options)), + moveMouse: (body: ComputerAPI.ComputerMoveMouseParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.moveMouse(this.sessionId, body, this.opt(options)), + pressKey: (body: ComputerAPI.ComputerPressKeyParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.pressKey(this.sessionId, body, this.opt(options)), + readClipboard: (options?: RequestOptions) => + this.sessionClient.browsers.computer.readClipboard(this.sessionId, this.opt(options)), + scroll: (body: ComputerAPI.ComputerScrollParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.scroll(this.sessionId, body, this.opt(options)), + setCursorVisibility: (body: ComputerAPI.ComputerSetCursorVisibilityParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.setCursorVisibility(this.sessionId, body, this.opt(options)), + typeText: (body: ComputerAPI.ComputerTypeTextParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.typeText(this.sessionId, body, this.opt(options)), + writeClipboard: (body: ComputerAPI.ComputerWriteClipboardParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.writeClipboard(this.sessionId, body, this.opt(options)), + }; + this.playwright = { + execute: (body: PlaywrightAPI.PlaywrightExecuteParams, options?: RequestOptions) => + this.sessionClient.browsers.playwright.execute(this.sessionId, body, this.opt(options)), + }; + } + + protected opt(options?: RequestOptions): RequestOptions | undefined { + return options; + } + + loadExtensions(body: BrowsersAPI.BrowserLoadExtensionsParams, options?: RequestOptions): APIPromise { + return this.sessionClient.browsers.loadExtensions(this.sessionId, body, this.opt(options)); + } +} diff --git a/src/lib/kernel-browser-session.ts b/src/lib/kernel-browser-session.ts index 45f33205..21f47a8e 100644 --- a/src/lib/kernel-browser-session.ts +++ b/src/lib/kernel-browser-session.ts @@ -1,70 +1,14 @@ import type { HeadersInit, RequestInfo, RequestInit } from '../internal/builtin-types'; import { Kernel } from '../client'; import { KernelError } from '../core/error'; -import { APIPromise } from '../core/api-promise'; -import type { RequestOptions } from '../internal/request-options'; -import type { FinalRequestOptions } from '../internal/request-options'; +import type { FinalRequestOptions, RequestOptions } from '../internal/request-options'; import type { BrowserCreateResponse, BrowserListResponse, - BrowserLoadExtensionsParams, BrowserRetrieveResponse, } from '../resources/browsers/browsers'; -import type { - ComputerBatchParams, - ComputerCaptureScreenshotParams, - ComputerClickMouseParams, - ComputerDragMouseParams, - ComputerGetMousePositionResponse, - ComputerMoveMouseParams, - ComputerPressKeyParams, - ComputerReadClipboardResponse, - ComputerScrollParams, - ComputerSetCursorVisibilityParams, - ComputerSetCursorVisibilityResponse, - ComputerTypeTextParams, - ComputerWriteClipboardParams, -} from '../resources/browsers/computer'; -import type { LogStreamParams } from '../resources/browsers/logs'; -import type { PlaywrightExecuteParams, PlaywrightExecuteResponse } from '../resources/browsers/playwright'; -import type { - ProcessExecParams, - ProcessExecResponse, - ProcessKillParams, - ProcessKillResponse, - ProcessResizeParams, - ProcessResizeResponse, - ProcessSpawnParams, - ProcessSpawnResponse, - ProcessStatusResponse, - ProcessStdinParams, - ProcessStdinResponse, - ProcessStdoutStreamResponse, -} from '../resources/browsers/process'; -import type { - ReplayListResponse, - ReplayStartParams, - ReplayStartResponse, -} from '../resources/browsers/replays'; -import type { - FCreateDirectoryParams, - FDeleteDirectoryParams, - FDeleteFileParams, - FDownloadDirZipParams, - FFileInfoParams, - FFileInfoResponse, - FListFilesParams, - FListFilesResponse, - FMoveParams, - FReadFileParams, - FSetFilePermissionsParams, - FUploadParams, - FUploadZipParams, - FWriteFileParams, -} from '../resources/browsers/fs/fs'; -import { Stream } from '../core/streaming'; -import type { LogEvent } from '../resources/shared'; import { buildHeaders } from '../internal/headers'; +import { GeneratedBrowserSessionBindings } from './generated/browser-session-bindings'; import { resolveBrowserTransport, type KernelBrowserLike, @@ -87,222 +31,29 @@ export interface BrowserFetchInit extends RequestInit { * through {@link BrowserCreateResponse.base_url} (browser session HTTP base URL for * the browser VM edge) with jwt query authentication. */ -export class KernelBrowserSession { - readonly sessionId: string; - private readonly sessionClient: Kernel; +export class KernelBrowserSession extends GeneratedBrowserSessionBindings { + protected override readonly sessionClient: Kernel; private readonly transport: ResolvedBrowserTransport; constructor(kernel: Kernel, browser: KernelBrowserInput) { - this.transport = resolveBrowserTransport(browser); - this.sessionId = this.transport.sessionId; - const baseURL = this.transport.defaultBaseURL; + const transport = resolveBrowserTransport(browser); + const sessionId = transport.sessionId; + const baseURL = transport.defaultBaseURL; if (!baseURL) { throw new KernelError( 'kernel.forBrowser requires browser.base_url from the Kernel API. Create or retrieve the browser and pass a response that includes base_url before using the browser session client.', ); } - this.sessionClient = createBrowserSessionKernel(kernel, { - ...this.transport, + + const sessionClient = createBrowserSessionKernel(kernel, { + ...transport, defaultBaseURL: baseURL, }); + super(sessionClient, sessionId); + this.sessionClient = sessionClient; + this.transport = transport; } - private opt(options?: RequestOptions): RequestOptions | undefined { - return options; - } - - loadExtensions(body: BrowserLoadExtensionsParams, options?: RequestOptions): APIPromise { - return this.sessionClient.browsers.loadExtensions(this.sessionId, body, this.opt(options)); - } - - readonly process = { - exec: (body: ProcessExecParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.process.exec(this.sessionId, body, this.opt(options)); - }, - kill: ( - processID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise => { - return this.sessionClient.browsers.process.kill( - processID, - { ...params, id: this.sessionId }, - this.opt(options), - ); - }, - resize: ( - processID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise => { - return this.sessionClient.browsers.process.resize( - processID, - { ...params, id: this.sessionId }, - this.opt(options), - ); - }, - spawn: (body: ProcessSpawnParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.process.spawn(this.sessionId, body, this.opt(options)); - }, - status: (processID: string, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.process.status(processID, { id: this.sessionId }, this.opt(options)); - }, - stdin: ( - processID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise => { - return this.sessionClient.browsers.process.stdin( - processID, - { ...params, id: this.sessionId }, - this.opt(options), - ); - }, - stdoutStream: ( - processID: string, - options?: RequestOptions, - ): APIPromise> => { - return this.sessionClient.browsers.process.stdoutStream( - processID, - { id: this.sessionId }, - this.opt(options), - ); - }, - }; - - readonly computer = { - batch: (body: ComputerBatchParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.batch(this.sessionId, body, this.opt(options)); - }, - captureScreenshot: ( - body: ComputerCaptureScreenshotParams | null | undefined, - options?: RequestOptions, - ): APIPromise => { - return this.sessionClient.browsers.computer.captureScreenshot( - this.sessionId, - body ?? {}, - this.opt(options), - ); - }, - clickMouse: (body: ComputerClickMouseParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.clickMouse(this.sessionId, body, this.opt(options)); - }, - dragMouse: (body: ComputerDragMouseParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.dragMouse(this.sessionId, body, this.opt(options)); - }, - getMousePosition: (options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.getMousePosition(this.sessionId, this.opt(options)); - }, - moveMouse: (body: ComputerMoveMouseParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.moveMouse(this.sessionId, body, this.opt(options)); - }, - pressKey: (body: ComputerPressKeyParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.pressKey(this.sessionId, body, this.opt(options)); - }, - readClipboard: (options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.readClipboard(this.sessionId, this.opt(options)); - }, - scroll: (body: ComputerScrollParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.scroll(this.sessionId, body, this.opt(options)); - }, - setCursorVisibility: ( - body: ComputerSetCursorVisibilityParams, - options?: RequestOptions, - ): APIPromise => { - return this.sessionClient.browsers.computer.setCursorVisibility( - this.sessionId, - body, - this.opt(options), - ); - }, - typeText: (body: ComputerTypeTextParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.typeText(this.sessionId, body, this.opt(options)); - }, - writeClipboard: (body: ComputerWriteClipboardParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.computer.writeClipboard(this.sessionId, body, this.opt(options)); - }, - }; - - readonly logs = { - stream: (query: LogStreamParams, options?: RequestOptions): APIPromise> => { - return this.sessionClient.browsers.logs.stream(this.sessionId, query, this.opt(options)); - }, - }; - - readonly playwright = { - execute: ( - body: PlaywrightExecuteParams, - options?: RequestOptions, - ): APIPromise => { - return this.sessionClient.browsers.playwright.execute(this.sessionId, body, this.opt(options)); - }, - }; - - readonly replays = { - list: (options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.replays.list(this.sessionId, this.opt(options)); - }, - download: (replayID: string, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.replays.download( - replayID, - { id: this.sessionId }, - this.opt(options), - ); - }, - start: ( - body: ReplayStartParams | null | undefined, - options?: RequestOptions, - ): APIPromise => { - return this.sessionClient.browsers.replays.start(this.sessionId, body ?? {}, this.opt(options)); - }, - stop: (replayID: string, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.replays.stop(replayID, { id: this.sessionId }, this.opt(options)); - }, - }; - - readonly fs = { - createDirectory: (body: FCreateDirectoryParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.createDirectory(this.sessionId, body, this.opt(options)); - }, - deleteDirectory: (body: FDeleteDirectoryParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.deleteDirectory(this.sessionId, body, this.opt(options)); - }, - deleteFile: (body: FDeleteFileParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.deleteFile(this.sessionId, body, this.opt(options)); - }, - downloadDirZip: (query: FDownloadDirZipParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.downloadDirZip(this.sessionId, query, this.opt(options)); - }, - fileInfo: (query: FFileInfoParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.fileInfo(this.sessionId, query, this.opt(options)); - }, - listFiles: (query: FListFilesParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.listFiles(this.sessionId, query, this.opt(options)); - }, - move: (body: FMoveParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.move(this.sessionId, body, this.opt(options)); - }, - readFile: (query: FReadFileParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.readFile(this.sessionId, query, this.opt(options)); - }, - setFilePermissions: (body: FSetFilePermissionsParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.setFilePermissions(this.sessionId, body, this.opt(options)); - }, - upload: (body: FUploadParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.upload(this.sessionId, body, this.opt(options)); - }, - uploadZip: (body: FUploadZipParams, options?: RequestOptions): APIPromise => { - return this.sessionClient.browsers.fs.uploadZip(this.sessionId, body, this.opt(options)); - }, - writeFile: ( - contents: string | ArrayBuffer | ArrayBufferView | Blob | DataView, - params: FWriteFileParams, - options?: RequestOptions, - ): APIPromise => { - return this.sessionClient.browsers.fs.writeFile(this.sessionId, contents, params, this.opt(options)); - }, - }; - /** * Issue an HTTP request through the browser VM network stack (Chrome), returning * the upstream response as a standard Fetch {@link Response}. Implemented via diff --git a/yarn.lock b/yarn.lock index f6eae3cd..84803891 100644 --- a/yarn.lock +++ b/yarn.lock @@ -828,6 +828,15 @@ dependencies: "@swc/counter" "^0.1.3" +"@ts-morph/common@~0.29.0": + version "0.29.0" + resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.29.0.tgz#bb1ed737f309c8270bb2e92207066343c1302ae2" + integrity sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg== + dependencies: + minimatch "^10.0.1" + path-browserify "^1.0.1" + tinyglobby "^0.2.14" + "@tsconfig/node10@^1.0.7": version "1.0.8" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" @@ -881,6 +890,13 @@ dependencies: "@babel/types" "^7.20.7" +"@types/busboy@^1.5.4": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@types/busboy/-/busboy-1.5.4.tgz#0038c31102ca90f2a7f0d8bc27ee5ebf1088e230" + integrity sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw== + dependencies: + "@types/node" "*" + "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" @@ -1263,6 +1279,13 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1365,6 +1388,11 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== +code-block-writer@^13.0.3: + version "13.0.3" + resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-13.0.3.tgz#90f8a84763a5012da7af61319dd638655ae90b5b" + integrity sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg== + collect-v8-coverage@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" @@ -1714,6 +1742,11 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + fflate@^0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" @@ -2587,7 +2620,7 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1, minimatch@^9.0.4, minimatch@^9.0.5: +minimatch@^10.0.1, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1, minimatch@^9.0.4, minimatch@^9.0.5: version "9.0.9" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== @@ -2794,6 +2827,11 @@ parse5@^6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -2824,6 +2862,11 @@ picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + pirates@^4.0.4: version "4.0.6" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" @@ -3054,6 +3097,11 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -3174,6 +3222,14 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +tinyglobby@^0.2.14: + version "0.2.16" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -3205,6 +3261,14 @@ ts-jest@^29.1.0: semver "^7.5.3" yargs-parser "^21.0.1" +ts-morph@^28.0.0: + version "28.0.0" + resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-28.0.0.tgz#cd3ebdf89742a32b06ea7327df6445364ee26631" + integrity sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g== + dependencies: + "@ts-morph/common" "~0.29.0" + code-block-writer "^13.0.3" + ts-node@^10.5.0: version "10.7.0" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.7.0.tgz#35d503d0fab3e2baa672a0e94f4b40653c2463f5" From 2f1227728f83205144169be48de7b4f145f920d7 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 10:58:02 -0400 Subject: [PATCH 05/19] refactor: replace browser-scoped client with browser routing cache Route direct-to-VM browser requests through the shared client cache so the SDK no longer needs the generated browser session wrapper layer. Made-with: Cursor --- examples/browser-scoped.ts | 20 +- package.json | 1 - scripts/build | 3 - scripts/generate-browser-session.ts | 332 --------------- scripts/lint | 7 - src/client.ts | 53 ++- src/index.ts | 8 +- src/lib/browser-routing.ts | 393 ++++++++++++++++++ src/lib/browser-transport.ts | 21 - src/lib/generated/browser-session-bindings.ts | 326 --------------- src/lib/kernel-browser-session.ts | 252 ----------- src/resources/browsers/browsers.ts | 10 + tests/lib/browser-routing.test.ts | 130 ++++++ tests/lib/browser-transport.test.ts | 21 +- tests/lib/kernel-browser-session.test.ts | 82 ---- 15 files changed, 590 insertions(+), 1069 deletions(-) delete mode 100644 scripts/generate-browser-session.ts create mode 100644 src/lib/browser-routing.ts delete mode 100644 src/lib/generated/browser-session-bindings.ts delete mode 100644 src/lib/kernel-browser-session.ts create mode 100644 tests/lib/browser-routing.test.ts delete mode 100644 tests/lib/kernel-browser-session.test.ts diff --git a/examples/browser-scoped.ts b/examples/browser-scoped.ts index 33f4c5f7..52146c9f 100644 --- a/examples/browser-scoped.ts +++ b/examples/browser-scoped.ts @@ -1,21 +1,23 @@ /** - * Browser-scoped client: call browser VM-routed browser APIs without repeating the - * session id, and run `fetch`-style HTTP through the browser network stack. - * - * Run after `yarn build` so `dist/` matches sources, or import from `src/` via - * ts-node with path aliases. + * Browser routing: keep the standard browser resource surface while routing + * allowlisted subresources and raw HTTP directly to the browser VM. */ import Kernel from '@onkernel/sdk'; async function main() { - const kernel = new Kernel(); + const kernel = new Kernel({ + browserRouting: { + enabled: true, + directToVMSubresources: ['computer'], + }, + }); const created = await kernel.browsers.create({}); - const browser = kernel.forBrowser(created); + kernel.browserRouteCache.prime(created); - await browser.computer.clickMouse({ x: 10, y: 10 }); + await kernel.browsers.computer.clickMouse(created.session_id, { x: 10, y: 10 }); - const page = await browser.fetch('https://example.com', { method: 'GET' }); + const page = await kernel.browsers.fetch(created.session_id, 'https://example.com', { method: 'GET' }); console.log('status', page.status); await kernel.browsers.deleteByID(created.session_id); diff --git a/package.json b/package.json index 08896410..961a11ec 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "prepublishOnly": "echo 'to publish, run yarn build && (cd dist; yarn publish)' && exit 1", "format": "./scripts/format", "prepare": "if ./scripts/utils/check-is-in-git-install.sh; then ./scripts/build && ./scripts/utils/git-swap.sh; fi", - "generate:browser-session": "ts-node -T scripts/generate-browser-session.ts", "tsn": "ts-node -r tsconfig-paths/register", "lint": "./scripts/lint", "fix": "./scripts/format" diff --git a/scripts/build b/scripts/build index 8d747f2f..a008cb00 100755 --- a/scripts/build +++ b/scripts/build @@ -6,9 +6,6 @@ cd "$(dirname "$0")/.." node scripts/utils/check-version.cjs -./node_modules/.bin/ts-node -T scripts/generate-browser-session.ts -./node_modules/.bin/prettier --write src/lib/generated/browser-session-bindings.ts >/dev/null - # Build into dist and will publish the package from there, # so that src/resources/foo.ts becomes /resources/foo.js # This way importing from `"@onkernel/sdk/resources/foo"` works diff --git a/scripts/generate-browser-session.ts b/scripts/generate-browser-session.ts deleted file mode 100644 index 4369163f..00000000 --- a/scripts/generate-browser-session.ts +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env -S node - -import fs from 'fs'; -import path from 'path'; -import { - Node, - Project, - SyntaxKind, - type MethodDeclaration, - type ParameterDeclaration, - type PropertyDeclaration, -} from 'ts-morph'; - -type ChildMeta = { - propName: string; - targetClass: string; -}; - -type MethodMeta = { - name: string; - signature: string; - returnType: string; - implArgs: string; -}; - -type ResourceMeta = { - className: string; - filePath: string; - importPath: string; - alias: string; - exportedTypeNames: Set; - children: ChildMeta[]; - methods: MethodMeta[]; -}; - -const repoRoot = path.resolve(__dirname, '..'); -const resourcesDir = path.join(repoRoot, 'src', 'resources', 'browsers'); -const outputFile = path.join(repoRoot, 'src', 'lib', 'generated', 'browser-session-bindings.ts'); - -const project = new Project({ - skipAddingFilesFromTsConfig: true, -}); - -project.addSourceFilesAtPaths(path.join(resourcesDir, '**/*.ts')); - -const sourceFiles = project - .getSourceFiles() - .filter((sf) => !sf.getBaseName().endsWith('.test.ts') && sf.getBaseName() !== 'index.ts'); - -const resourceByClass = new Map(); - -for (const sf of sourceFiles) { - for (const classDecl of sf.getClasses()) { - if (classDecl.getName() == null) continue; - if (!extendsAPIResource(classDecl)) continue; - - const className = classDecl.getNameOrThrow(); - const importPath = toImportPath(path.relative(path.dirname(outputFile), sf.getFilePath())); - const alias = `${className}API`; - const exportedTypeNames = new Set( - sf - .getStatements() - .filter( - (stmt) => - Node.isInterfaceDeclaration(stmt) || - Node.isTypeAliasDeclaration(stmt) || - Node.isEnumDeclaration(stmt), - ) - .map((stmt) => { - if ( - Node.isInterfaceDeclaration(stmt) || - Node.isTypeAliasDeclaration(stmt) || - Node.isEnumDeclaration(stmt) - ) { - return stmt.getName(); - } - return ''; - }) - .filter(Boolean), - ); - - const meta: ResourceMeta = { - className, - filePath: sf.getFilePath(), - importPath, - alias, - exportedTypeNames, - children: extractChildren(classDecl), - methods: [], - }; - resourceByClass.set(className, meta); - } -} - -for (const sf of sourceFiles) { - for (const classDecl of sf.getClasses()) { - const className = classDecl.getName(); - if (!className) continue; - const meta = resourceByClass.get(className); - if (!meta) continue; - - for (const method of classDecl.getMethods()) { - const parsed = parseMethod(meta, method); - if (parsed) meta.methods.push(parsed); - } - } -} - -const browserMeta = resourceByClass.get('Browsers'); -if (!browserMeta) { - throw new Error('Could not find Browsers resource'); -} - -const ordered = orderResources(browserMeta, resourceByClass); -const importLines = ordered - .map((meta) => `import type * as ${meta.alias} from '${meta.importPath}';`) - .join('\n'); - -const interfaces = ordered.map((meta) => emitInterface(meta, resourceByClass)).join('\n\n'); - -const constructorAssignments = browserMeta.children - .map((child) => emitAssignment(child, resourceByClass)) - .join('\n '); - -const rootMethods = browserMeta.methods.map((method) => emitMethod(method, 'browsers')).join('\n\n'); - -const output = `// This file is generated by scripts/generate-browser-session.ts.\n// Do not edit by hand.\n\nimport type { Kernel } from '../../client';\nimport type { APIPromise } from '../../core/api-promise';\nimport type { RequestOptions } from '../../internal/request-options';\nimport type { Stream } from '../../core/streaming';\nimport type * as Shared from '../../resources/shared';\n${importLines}\n\n${interfaces}\n\nexport class GeneratedBrowserSessionBindings {\n protected readonly sessionClient: Kernel;\n readonly sessionId: string;\n\n readonly ${browserMeta.children - .map((child) => `${child.propName}: ${bindingName(child.targetClass)}`) - .join( - '\n readonly ', - )};\n\n constructor(sessionClient: Kernel, sessionId: string) {\n this.sessionClient = sessionClient;\n this.sessionId = sessionId;\n ${constructorAssignments}\n }\n\n protected opt(options?: RequestOptions): RequestOptions | undefined {\n return options;\n }\n\n${indent( - rootMethods, - 2, -)}\n}\n`; - -fs.mkdirSync(path.dirname(outputFile), { recursive: true }); -fs.writeFileSync(outputFile, output); - -function extendsAPIResource(classDecl: import('ts-morph').ClassDeclaration): boolean { - const ext = classDecl.getExtends(); - if (!ext) return false; - const text = ext.getExpression().getText(); - return text === 'APIResource'; -} - -function extractChildren(classDecl: import('ts-morph').ClassDeclaration): ChildMeta[] { - return classDecl - .getProperties() - .filter((prop) => !prop.getName().startsWith('with_')) - .map((prop) => { - const targetClass = childClassName(prop); - if (!targetClass) return null; - return { propName: prop.getName(), targetClass }; - }) - .filter((v): v is ChildMeta => v !== null); -} - -function childClassName(prop: PropertyDeclaration): string | null { - const init = prop.getInitializer(); - if (!init || !Node.isNewExpression(init)) return null; - const expr = init.getExpression().getText(); - const last = expr.split('.').pop() || expr; - return last; -} - -function parseMethod(meta: ResourceMeta, method: MethodDeclaration): MethodMeta | null { - if (!isPublicMethod(method)) return null; - const pathText = getPathTemplate(method); - if (!pathText) return null; - if (meta.className === 'Browsers' && !pathText.includes('/browsers/${id}/')) return null; - - const params = method.getParameters(); - const idParam = params[0]?.getName() === 'id' ? params[0] : undefined; - const paramsIdName = detectParamsIdParam(method); - if (!idParam && !paramsIdName) return null; - - const publicParams = params - .filter((param) => param !== idParam) - .map((param) => formatParam(meta, param, paramsIdName, true)) - .join(', '); - - const implArgs = params - .map((param) => { - const name = param.getName(); - if (param === idParam) return 'this.sessionId'; - if (paramsIdName && name === paramsIdName) return `{ ...${name}, id: this.sessionId }`; - if (name === 'options') return 'this.opt(options)'; - return name; - }) - .join(', '); - - return { - name: method.getName(), - signature: publicParams, - returnType: rewriteType(meta, method.getReturnTypeNodeOrThrow().getText()), - implArgs, - }; -} - -function isPublicMethod(method: MethodDeclaration): boolean { - const name = method.getName(); - if (name.startsWith('_')) return false; - if (method.isStatic()) return false; - return true; -} - -function getPathTemplate(method: MethodDeclaration): string | null { - const tag = method - .getDescendantsOfKind(SyntaxKind.TaggedTemplateExpression) - .find((node) => node.getTag().getText() === 'path'); - return tag?.getTemplate().getText() ?? null; -} - -function detectParamsIdParam(method: MethodDeclaration): string | null { - const body = method.getBodyText() ?? ''; - const match = body.match(/const\s+\{\s*id(?:\s*,\s*\.\.\.\w+)?\s*\}\s*=\s*(\w+)/); - return match?.[1] ?? null; -} - -function formatParam( - meta: ResourceMeta, - param: ParameterDeclaration, - paramsIdName: string | null, - includeInitializer: boolean, -): string { - const name = param.getName(); - let typeText = param.getTypeNodeOrThrow().getText(); - typeText = rewriteType(meta, typeText); - if (paramsIdName && name === paramsIdName) { - typeText = `Omit<${typeText}, 'id'>`; - } - const initializer = includeInitializer ? param.getInitializer()?.getText() : undefined; - const question = param.hasQuestionToken() ? '?' : ''; - return `${name}${question}: ${typeText}${initializer ? ` = ${initializer}` : ''}`; -} - -function rewriteType(meta: ResourceMeta, text: string): string { - let out = text; - const typeNames = Array.from(meta.exportedTypeNames).sort((a, b) => b.length - a.length); - for (const name of typeNames) { - out = out.replace(new RegExp(`\\b${name}\\b`, 'g'), `${meta.alias}.${name}`); - } - return out; -} - -function orderResources(root: ResourceMeta, all: Map): ResourceMeta[] { - const out: ResourceMeta[] = []; - const seen = new Set(); - const visit = (meta: ResourceMeta) => { - if (seen.has(meta.className)) return; - seen.add(meta.className); - for (const child of meta.children) { - const childMeta = all.get(child.targetClass); - if (childMeta) visit(childMeta); - } - out.push(meta); - }; - visit(root); - return out.filter((meta) => meta.className !== 'Browsers').concat(root); -} - -function emitInterface(meta: ResourceMeta, all: Map): string { - const lines: string[] = []; - for (const method of meta.methods) { - const noInitSignature = method.signature.replace(/\s*=\s*[^,)+]+/g, ''); - lines.push(` ${method.name}(${noInitSignature}): ${method.returnType};`); - } - for (const child of meta.children) { - if (all.has(child.targetClass)) { - lines.push(` ${child.propName}: ${bindingName(child.targetClass)};`); - } - } - return `export interface ${bindingName(meta.className)} {\n${lines.join('\n')}\n}`; -} - -function bindingName(className: string): string { - return `BrowserSession${className}Bindings`; -} - -function emitAssignment(child: ChildMeta, all: Map): string { - const meta = all.get(child.targetClass)!; - const methodLines = meta.methods.map((method) => { - return `${method.name}: (${method.signature}) => this.sessionClient.browsers.${resourceCallPath( - meta.filePath, - )}.${method.name}(${method.implArgs}),`; - }); - const childLines = meta.children.map((nested) => emitNestedObject(nested, all)); - return `this.${child.propName} = {\n ${[...methodLines, ...childLines].join('\n ')}\n };`; -} - -function emitNestedObject(child: ChildMeta, all: Map): string { - const meta = all.get(child.targetClass)!; - const methodLines = meta.methods.map((method) => { - return `${method.name}: (${method.signature}) => this.sessionClient.browsers.${resourceCallPath( - meta.filePath, - )}.${method.name}(${method.implArgs}),`; - }); - const nestedLines = meta.children.map((nested) => emitNestedObject(nested, all)); - return `${child.propName}: {\n ${[...methodLines, ...nestedLines].join('\n ')}\n },`; -} - -function resourceCallPath(filePath: string): string { - const rel = path.relative(resourcesDir, filePath).replace(/\\/g, '/').replace(/\.ts$/, ''); - const segments = rel.split('/'); - if (segments[segments.length - 1] === 'fs') { - return 'fs'; - } - if (segments[0] === 'fs') { - return ['fs', ...segments.slice(1)].join('.'); - } - if (segments[0] === 'browsers') { - return segments.slice(1).join('.'); - } - return segments.join('.'); -} - -function emitMethod(method: MethodMeta, resourcePrefix: string): string { - return `${method.name}(${method.signature}): ${method.returnType} {\n return this.sessionClient.${resourcePrefix}.${method.name}(${method.implArgs});\n }`; -} - -function toImportPath(relPath: string): string { - const normalized = relPath.replace(/\\/g, '/').replace(/\.ts$/, ''); - return normalized.startsWith('.') ? normalized : `./${normalized}`; -} - -function indent(value: string, depth: number): string { - const prefix = ' '.repeat(depth); - return value - .split('\n') - .map((line) => (line.length ? `${prefix}${line}` : line)) - .join('\n'); -} diff --git a/scripts/lint b/scripts/lint index caaa979b..3ffb78a6 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,13 +4,6 @@ set -e cd "$(dirname "$0")/.." -echo "==> Regenerating browser session bindings" -./node_modules/.bin/ts-node -T scripts/generate-browser-session.ts -./node_modules/.bin/prettier --write src/lib/generated/browser-session-bindings.ts >/dev/null - -echo "==> Verifying generated browser session bindings are committed" -git diff --exit-code -- src/lib/generated/browser-session-bindings.ts - echo "==> Running eslint" ./node_modules/.bin/eslint . diff --git a/src/client.ts b/src/client.ts index ea231c09..27164d10 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, + createRoutingFetch, + type BrowserRoutingOptions, +} from './lib/browser-routing'; import { BrowserPool, BrowserPoolAcquireParams, @@ -67,7 +72,6 @@ import { Deployments, } from './resources/deployments'; import { KernelApp } from './core/app-framework'; -import { KernelBrowserSession, type KernelBrowserInput } from './lib/kernel-browser-session'; import { ExtensionDownloadFromChromeStoreParams, ExtensionListResponse, @@ -195,6 +199,11 @@ export interface ClientOptions { */ fetch?: Fetch | undefined; + /** + * Configure direct-to-VM routing for browser subresource requests. + */ + browserRouting?: BrowserRoutingOptions | undefined; + /** * The maximum number of times that the client will retry a request in case of a * temporary failure, like a network error or a 5XX error from the server. @@ -248,9 +257,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. @@ -313,7 +324,16 @@ 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 = options.browserRouting?.cache ?? new BrowserRouteCache(); + this.fetch = + options.browserRouting?.enabled ? + createRoutingFetch(this.rawFetch, { + apiBaseURL: this.baseURL, + directToVMSubresources: options.browserRouting.directToVMSubresources ?? [], + cache: this.browserRouteCache, + }) + : this.rawFetch; this.#encoder = Opts.FallbackEncoder; this._options = options; @@ -325,6 +345,23 @@ export class Kernel { * Create a new client instance re-using the same options given to the current client with optional overriding. */ withOptions(options: Partial): this { + const currentRouting = this._options.browserRouting; + const nextBrowserRouting = + options.browserRouting === undefined ? + { + ...(currentRouting ?? {}), + cache: currentRouting?.cache ?? this.browserRouteCache, + } + : options.browserRouting.enabled ? + { + ...options.browserRouting, + cache: options.browserRouting.cache ?? this.browserRouteCache, + } + : { + ...options.browserRouting, + cache: options.browserRouting.cache ?? this.browserRouteCache, + }; + const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ ...this._options, environment: options.environment ? options.environment : undefined, @@ -333,10 +370,11 @@ 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, + browserRouting: nextBrowserRouting, }); return client; } @@ -882,15 +920,6 @@ export class Kernel { return new KernelApp(name); } - /** - * Returns a browser-scoped client: subresource calls omit the session id and - * are routed through {@link BrowserCreateResponse.base_url} (browser session - * HTTP base URL for the browser VM edge). Requires base_url on the browser object. - */ - public forBrowser(browser: KernelBrowserInput): KernelBrowserSession { - return new KernelBrowserSession(this, browser); - } - static Kernel = this; static DEFAULT_TIMEOUT = 60000; // 1 minute diff --git a/src/index.ts b/src/index.ts index eaafa63a..5692ba6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,11 +6,11 @@ export { type Uploadable, toFile } from './core/uploads'; export { APIPromise } from './core/api-promise'; export { Kernel, type ClientOptions } from './client'; export { - KernelBrowserSession, + BrowserRouteCache, type BrowserFetchInit, - type KernelBrowserInput, -} from './lib/kernel-browser-session'; -export { type KernelBrowserLike, type ResolvedBrowserTransport } from './lib/browser-transport'; + type BrowserRoute, + type BrowserRoutingOptions, +} from './lib/browser-routing'; export { PagePromise } from './core/pagination'; export { KernelError, diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts new file mode 100644 index 00000000..ff5c2b86 --- /dev/null +++ b/src/lib/browser-routing.ts @@ -0,0 +1,393 @@ +import type { RequestInfo, RequestInit } from '../internal/builtin-types'; +import { KernelError } from '../core/error'; +import { buildHeaders } from '../internal/headers'; +import type { Fetch } from '../internal/builtin-types'; +import type { FinalRequestOptions, RequestOptions } from '../internal/request-options'; +import type { HTTPMethod } from '../internal/types'; +import { parseJwtFromCdpWsUrl } from './browser-transport'; +import type { Kernel } from '../client'; + +export interface BrowserFetchInit extends RequestInit { + timeout_ms?: number; +} + +export type BrowserRoute = { + sessionId: string; + baseURL: string; + jwt?: string | undefined; +}; + +export interface BrowserRoutingOptions { + enabled?: boolean; + directToVMSubresources?: string[] | undefined; + cache?: BrowserRouteCache | undefined; +} + +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, normalizeRoute(route)); + } + + delete(sessionId: string): void { + this.entries.delete(sessionId); + } + + clear(): void { + this.entries.clear(); + } + + prime(browser: unknown): BrowserRoute | undefined { + const route = browserRouteFromValue(browser); + if (!route) { + return undefined; + } + this.set(route); + return route; + } + + values(): BrowserRoute[] { + return [...this.entries.values()]; + } +} + +export function createRoutingFetch( + innerFetch: Fetch, + { + apiBaseURL, + directToVMSubresources, + cache, + }: { + apiBaseURL: string; + directToVMSubresources: Iterable; + cache: BrowserRouteCache; + }, +): Fetch { + const allowed = new Set([...directToVMSubresources].map((value) => value.trim()).filter(Boolean)); + const apiOrigin = new URL(apiBaseURL).origin; + + return async (input, init) => { + const request = new Request(input as RequestInfo, init); + const match = matchDirectToVMRequest(request.url, apiOrigin, allowed); + + const response = match ? await fetchDirectToVM(innerFetch, request, match, cache) : await innerFetch(input, init); + await sniffAndPrimeCache(response, cache); + return response; + }; +} + +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`, + ); + } + if (!route.jwt) { + throw new KernelError(`browser.fetch requires a browser session jwt for ${sessionId}`); + } + + 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 methodLower = normalizeMethod(method); + + const requestOptions: FinalRequestOptions = { + method: methodLower, + 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 normalizeRoute(route: BrowserRoute): BrowserRoute { + return { + sessionId: route.sessionId.trim(), + baseURL: route.baseURL.trim(), + jwt: route.jwt?.trim() || 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; + return { + sessionId, + baseURL, + jwt: explicitJWT || parseJwtFromCdpWsUrl(cdpWsURL) || undefined, + }; +} + +async function sniffAndPrimeCache(response: Response, cache: BrowserRouteCache): Promise { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + if (!contentType.includes('application/json')) { + return; + } + + try { + const body = await response.clone().json(); + primeRoutesFromJSON(body, cache); + } catch { + // Ignore malformed or non-JSON bodies returned with a JSON content type. + } +} + +function primeRoutesFromJSON(value: unknown, cache: BrowserRouteCache): void { + const route = browserRouteFromValue(value); + if (route) { + cache.set(route); + } + + if (Array.isArray(value)) { + for (const item of value) { + primeRoutesFromJSON(item, cache); + } + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + for (const child of Object.values(value as Record)) { + if (typeof child === 'object' && child !== null) { + primeRoutesFromJSON(child, cache); + } + } +} + +function matchDirectToVMRequest( + rawURL: string, + apiOrigin: string, + allowed: ReadonlySet, +): { sessionId: string; subresource: string; rest: string } | undefined { + const url = new URL(rawURL); + if (url.origin !== apiOrigin) { + return undefined; + } + + const match = url.pathname.match(/^\/(?:v\d+\/)?browsers\/([^/]+)\/([^/]+)(\/.*)?$/); + if (!match) { + return undefined; + } + + const sessionId = decodeURIComponent(match[1] ?? ''); + const subresource = match[2] ?? ''; + if (!sessionId || !allowed.has(subresource)) { + return undefined; + } + + return { + sessionId, + subresource, + rest: match[3] ?? '', + }; +} + +async function fetchDirectToVM( + innerFetch: Fetch, + request: Request, + match: { sessionId: string; subresource: string; rest: string }, + cache: BrowserRouteCache, +): Promise { + const route = cache.get(match.sessionId); + if (!route) { + return innerFetch(request); + } + + const target = new URL(joinURL(route.baseURL, `/${match.subresource}${match.rest}`)); + const current = new URL(request.url); + current.searchParams.forEach((value, key) => { + if (key !== 'jwt') { + target.searchParams.append(key, value); + } + }); + if (route.jwt && !target.searchParams.get('jwt')) { + target.searchParams.set('jwt', route.jwt); + } + + const headers = new Headers(request.headers); + headers.delete('authorization'); + const method = request.method.toUpperCase(); + const init: RequestInit = { + method, + headers, + redirect: request.redirect, + signal: request.signal, + }; + if (method !== 'GET' && method !== 'HEAD' && request.body) { + init.body = request.body; + init.duplex = 'half'; + } + + return innerFetch(new Request(target.toString(), init)); +} + +function joinURL(baseURL: string, path: string): string { + return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; +} + +function normalizeMethod(method: string): HTTPMethod { + const methodLower = method.toLowerCase(); + const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); + 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 merged = new Headers(input.headers); + if (init?.headers) { + const extra = new Headers(init.headers); + extra.forEach((value, key) => { + merged.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: merged, + }; + const mergedBody = init?.body ?? input.body; + if (mergedBody !== undefined && mergedBody !== null) { + out.body = mergedBody; + } + const mergedSignal = init?.signal ?? input.signal; + if (mergedSignal !== undefined) { + out.signal = mergedSignal; + } + if (init?.duplex !== undefined) { + out.duplex = init.duplex; + } + if (timeoutFromInit !== undefined) { + out.timeout_ms = timeoutFromInit; + } + return out; + } + + const url = input instanceof URL ? input.href : String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + const headers = new Headers(init?.headers); + const out: { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; + } = { url, method, 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) => { + const lower = key.toLowerCase(); + if ( + lower === 'accept' || + lower === 'content-length' || + lower === 'connection' || + lower === 'keep-alive' || + lower === 'proxy-authenticate' || + lower === 'proxy-authorization' || + lower === 'te' || + lower === 'trailers' || + lower === 'transfer-encoding' || + lower === 'upgrade' + ) { + return; + } + out[key] = value; + }); + return out; +} diff --git a/src/lib/browser-transport.ts b/src/lib/browser-transport.ts index d355038f..2554b858 100644 --- a/src/lib/browser-transport.ts +++ b/src/lib/browser-transport.ts @@ -1,10 +1,6 @@ -import type { RequestOptions } from '../internal/request-options'; - /** * Resolved HTTP routing for a browser session. When {@link ResolvedBrowserTransport.defaultBaseURL} * is set, requests use that browser session base URL plus a per-request jwt query param. - * A future client-wide browser-id → base_url cache can plug in by supplying an alternate - * resolver before constructing {@link KernelBrowserSession}. */ export type ResolvedBrowserTransport = { sessionId: string; @@ -43,20 +39,3 @@ export function resolveBrowserTransport(browser: KernelBrowserLike): ResolvedBro return { sessionId, defaultBaseURL, jwt }; } -export function mergeBrowserScopedRequestOptions( - transport: ResolvedBrowserTransport, - options?: RequestOptions, -): RequestOptions | undefined { - if (!transport.defaultBaseURL) { - return options; - } - const next: RequestOptions = { ...options, defaultBaseURL: transport.defaultBaseURL }; - if (transport.jwt) { - const prev = - options?.query && typeof options.query === 'object' && !Array.isArray(options.query) ? - (options.query as Record) - : {}; - next.query = { ...prev, jwt: transport.jwt }; - } - return next; -} diff --git a/src/lib/generated/browser-session-bindings.ts b/src/lib/generated/browser-session-bindings.ts deleted file mode 100644 index 4190c103..00000000 --- a/src/lib/generated/browser-session-bindings.ts +++ /dev/null @@ -1,326 +0,0 @@ -// This file is generated by scripts/generate-browser-session.ts. -// Do not edit by hand. - -import type { Kernel } from '../../client'; -import type { APIPromise } from '../../core/api-promise'; -import type { RequestOptions } from '../../internal/request-options'; -import type { Stream } from '../../core/streaming'; -import type * as Shared from '../../resources/shared'; -import type * as ReplaysAPI from '../../resources/browsers/replays'; -import type * as WatchAPI from '../../resources/browsers/fs/watch'; -import type * as FsAPI from '../../resources/browsers/fs/fs'; -import type * as ProcessAPI from '../../resources/browsers/process'; -import type * as LogsAPI from '../../resources/browsers/logs'; -import type * as ComputerAPI from '../../resources/browsers/computer'; -import type * as PlaywrightAPI from '../../resources/browsers/playwright'; -import type * as BrowsersAPI from '../../resources/browsers/browsers'; - -export interface BrowserSessionReplaysBindings { - list(options?: RequestOptions): APIPromise; - download( - replayID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise; - start( - body: ReplaysAPI.ReplayStartParams | null | undefined, - options?: RequestOptions, - ): APIPromise; - stop( - replayID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise; -} - -export interface BrowserSessionWatchBindings { - events( - watchID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise>; - start(body: WatchAPI.WatchStartParams, options?: RequestOptions): APIPromise; - stop( - watchID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise; -} - -export interface BrowserSessionFsBindings { - createDirectory(body: FsAPI.FCreateDirectoryParams, options?: RequestOptions): APIPromise; - deleteDirectory(body: FsAPI.FDeleteDirectoryParams, options?: RequestOptions): APIPromise; - deleteFile(body: FsAPI.FDeleteFileParams, options?: RequestOptions): APIPromise; - downloadDirZip(query: FsAPI.FDownloadDirZipParams, options?: RequestOptions): APIPromise; - fileInfo(query: FsAPI.FFileInfoParams, options?: RequestOptions): APIPromise; - listFiles(query: FsAPI.FListFilesParams, options?: RequestOptions): APIPromise; - move(body: FsAPI.FMoveParams, options?: RequestOptions): APIPromise; - readFile(query: FsAPI.FReadFileParams, options?: RequestOptions): APIPromise; - setFilePermissions(body: FsAPI.FSetFilePermissionsParams, options?: RequestOptions): APIPromise; - upload(body: FsAPI.FUploadParams, options?: RequestOptions): APIPromise; - uploadZip(body: FsAPI.FUploadZipParams, options?: RequestOptions): APIPromise; - writeFile( - contents: string | ArrayBuffer | ArrayBufferView | Blob | DataView, - params: FsAPI.FWriteFileParams, - options?: RequestOptions, - ): APIPromise; - watch: BrowserSessionWatchBindings; -} - -export interface BrowserSessionProcessBindings { - exec( - body: ProcessAPI.ProcessExecParams, - options?: RequestOptions, - ): APIPromise; - kill( - processID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise; - resize( - processID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise; - spawn( - body: ProcessAPI.ProcessSpawnParams, - options?: RequestOptions, - ): APIPromise; - status( - processID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise; - stdin( - processID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise; - stdoutStream( - processID: string, - params: Omit, - options?: RequestOptions, - ): APIPromise>; -} - -export interface BrowserSessionLogsBindings { - stream(query: LogsAPI.LogStreamParams, options?: RequestOptions): APIPromise>; -} - -export interface BrowserSessionComputerBindings { - batch(body: ComputerAPI.ComputerBatchParams, options?: RequestOptions): APIPromise; - captureScreenshot( - body: ComputerAPI.ComputerCaptureScreenshotParams | null | undefined, - options?: RequestOptions, - ): APIPromise; - clickMouse(body: ComputerAPI.ComputerClickMouseParams, options?: RequestOptions): APIPromise; - dragMouse(body: ComputerAPI.ComputerDragMouseParams, options?: RequestOptions): APIPromise; - getMousePosition(options?: RequestOptions): APIPromise; - moveMouse(body: ComputerAPI.ComputerMoveMouseParams, options?: RequestOptions): APIPromise; - pressKey(body: ComputerAPI.ComputerPressKeyParams, options?: RequestOptions): APIPromise; - readClipboard(options?: RequestOptions): APIPromise; - scroll(body: ComputerAPI.ComputerScrollParams, options?: RequestOptions): APIPromise; - setCursorVisibility( - body: ComputerAPI.ComputerSetCursorVisibilityParams, - options?: RequestOptions, - ): APIPromise; - typeText(body: ComputerAPI.ComputerTypeTextParams, options?: RequestOptions): APIPromise; - writeClipboard(body: ComputerAPI.ComputerWriteClipboardParams, options?: RequestOptions): APIPromise; -} - -export interface BrowserSessionPlaywrightBindings { - execute( - body: PlaywrightAPI.PlaywrightExecuteParams, - options?: RequestOptions, - ): APIPromise; -} - -export interface BrowserSessionBrowsersBindings { - loadExtensions(body: BrowsersAPI.BrowserLoadExtensionsParams, options?: RequestOptions): APIPromise; - replays: BrowserSessionReplaysBindings; - fs: BrowserSessionFsBindings; - process: BrowserSessionProcessBindings; - logs: BrowserSessionLogsBindings; - computer: BrowserSessionComputerBindings; - playwright: BrowserSessionPlaywrightBindings; -} - -export class GeneratedBrowserSessionBindings { - protected readonly sessionClient: Kernel; - readonly sessionId: string; - - readonly replays: BrowserSessionReplaysBindings; - readonly fs: BrowserSessionFsBindings; - readonly process: BrowserSessionProcessBindings; - readonly logs: BrowserSessionLogsBindings; - readonly computer: BrowserSessionComputerBindings; - readonly playwright: BrowserSessionPlaywrightBindings; - - constructor(sessionClient: Kernel, sessionId: string) { - this.sessionClient = sessionClient; - this.sessionId = sessionId; - this.replays = { - list: (options?: RequestOptions) => - this.sessionClient.browsers.replays.list(this.sessionId, this.opt(options)), - download: ( - replayID: string, - params: Omit, - options?: RequestOptions, - ) => - this.sessionClient.browsers.replays.download( - replayID, - { ...params, id: this.sessionId }, - this.opt(options), - ), - start: (body: ReplaysAPI.ReplayStartParams | null | undefined = {}, options?: RequestOptions) => - this.sessionClient.browsers.replays.start(this.sessionId, body, this.opt(options)), - stop: (replayID: string, params: Omit, options?: RequestOptions) => - this.sessionClient.browsers.replays.stop( - replayID, - { ...params, id: this.sessionId }, - this.opt(options), - ), - }; - this.fs = { - createDirectory: (body: FsAPI.FCreateDirectoryParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.createDirectory(this.sessionId, body, this.opt(options)), - deleteDirectory: (body: FsAPI.FDeleteDirectoryParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.deleteDirectory(this.sessionId, body, this.opt(options)), - deleteFile: (body: FsAPI.FDeleteFileParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.deleteFile(this.sessionId, body, this.opt(options)), - downloadDirZip: (query: FsAPI.FDownloadDirZipParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.downloadDirZip(this.sessionId, query, this.opt(options)), - fileInfo: (query: FsAPI.FFileInfoParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.fileInfo(this.sessionId, query, this.opt(options)), - listFiles: (query: FsAPI.FListFilesParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.listFiles(this.sessionId, query, this.opt(options)), - move: (body: FsAPI.FMoveParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.move(this.sessionId, body, this.opt(options)), - readFile: (query: FsAPI.FReadFileParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.readFile(this.sessionId, query, this.opt(options)), - setFilePermissions: (body: FsAPI.FSetFilePermissionsParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.setFilePermissions(this.sessionId, body, this.opt(options)), - upload: (body: FsAPI.FUploadParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.upload(this.sessionId, body, this.opt(options)), - uploadZip: (body: FsAPI.FUploadZipParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.uploadZip(this.sessionId, body, this.opt(options)), - writeFile: ( - contents: string | ArrayBuffer | ArrayBufferView | Blob | DataView, - params: FsAPI.FWriteFileParams, - options?: RequestOptions, - ) => this.sessionClient.browsers.fs.writeFile(this.sessionId, contents, params, this.opt(options)), - watch: { - events: (watchID: string, params: Omit, options?: RequestOptions) => - this.sessionClient.browsers.fs.watch.events( - watchID, - { ...params, id: this.sessionId }, - this.opt(options), - ), - start: (body: WatchAPI.WatchStartParams, options?: RequestOptions) => - this.sessionClient.browsers.fs.watch.start(this.sessionId, body, this.opt(options)), - stop: (watchID: string, params: Omit, options?: RequestOptions) => - this.sessionClient.browsers.fs.watch.stop( - watchID, - { ...params, id: this.sessionId }, - this.opt(options), - ), - }, - }; - this.process = { - exec: (body: ProcessAPI.ProcessExecParams, options?: RequestOptions) => - this.sessionClient.browsers.process.exec(this.sessionId, body, this.opt(options)), - kill: (processID: string, params: Omit, options?: RequestOptions) => - this.sessionClient.browsers.process.kill( - processID, - { ...params, id: this.sessionId }, - this.opt(options), - ), - resize: ( - processID: string, - params: Omit, - options?: RequestOptions, - ) => - this.sessionClient.browsers.process.resize( - processID, - { ...params, id: this.sessionId }, - this.opt(options), - ), - spawn: (body: ProcessAPI.ProcessSpawnParams, options?: RequestOptions) => - this.sessionClient.browsers.process.spawn(this.sessionId, body, this.opt(options)), - status: ( - processID: string, - params: Omit, - options?: RequestOptions, - ) => - this.sessionClient.browsers.process.status( - processID, - { ...params, id: this.sessionId }, - this.opt(options), - ), - stdin: ( - processID: string, - params: Omit, - options?: RequestOptions, - ) => - this.sessionClient.browsers.process.stdin( - processID, - { ...params, id: this.sessionId }, - this.opt(options), - ), - stdoutStream: ( - processID: string, - params: Omit, - options?: RequestOptions, - ) => - this.sessionClient.browsers.process.stdoutStream( - processID, - { ...params, id: this.sessionId }, - this.opt(options), - ), - }; - this.logs = { - stream: (query: LogsAPI.LogStreamParams, options?: RequestOptions) => - this.sessionClient.browsers.logs.stream(this.sessionId, query, this.opt(options)), - }; - this.computer = { - batch: (body: ComputerAPI.ComputerBatchParams, options?: RequestOptions) => - this.sessionClient.browsers.computer.batch(this.sessionId, body, this.opt(options)), - captureScreenshot: ( - body: ComputerAPI.ComputerCaptureScreenshotParams | null | undefined = {}, - options?: RequestOptions, - ) => this.sessionClient.browsers.computer.captureScreenshot(this.sessionId, body, this.opt(options)), - clickMouse: (body: ComputerAPI.ComputerClickMouseParams, options?: RequestOptions) => - this.sessionClient.browsers.computer.clickMouse(this.sessionId, body, this.opt(options)), - dragMouse: (body: ComputerAPI.ComputerDragMouseParams, options?: RequestOptions) => - this.sessionClient.browsers.computer.dragMouse(this.sessionId, body, this.opt(options)), - getMousePosition: (options?: RequestOptions) => - this.sessionClient.browsers.computer.getMousePosition(this.sessionId, this.opt(options)), - moveMouse: (body: ComputerAPI.ComputerMoveMouseParams, options?: RequestOptions) => - this.sessionClient.browsers.computer.moveMouse(this.sessionId, body, this.opt(options)), - pressKey: (body: ComputerAPI.ComputerPressKeyParams, options?: RequestOptions) => - this.sessionClient.browsers.computer.pressKey(this.sessionId, body, this.opt(options)), - readClipboard: (options?: RequestOptions) => - this.sessionClient.browsers.computer.readClipboard(this.sessionId, this.opt(options)), - scroll: (body: ComputerAPI.ComputerScrollParams, options?: RequestOptions) => - this.sessionClient.browsers.computer.scroll(this.sessionId, body, this.opt(options)), - setCursorVisibility: (body: ComputerAPI.ComputerSetCursorVisibilityParams, options?: RequestOptions) => - this.sessionClient.browsers.computer.setCursorVisibility(this.sessionId, body, this.opt(options)), - typeText: (body: ComputerAPI.ComputerTypeTextParams, options?: RequestOptions) => - this.sessionClient.browsers.computer.typeText(this.sessionId, body, this.opt(options)), - writeClipboard: (body: ComputerAPI.ComputerWriteClipboardParams, options?: RequestOptions) => - this.sessionClient.browsers.computer.writeClipboard(this.sessionId, body, this.opt(options)), - }; - this.playwright = { - execute: (body: PlaywrightAPI.PlaywrightExecuteParams, options?: RequestOptions) => - this.sessionClient.browsers.playwright.execute(this.sessionId, body, this.opt(options)), - }; - } - - protected opt(options?: RequestOptions): RequestOptions | undefined { - return options; - } - - loadExtensions(body: BrowsersAPI.BrowserLoadExtensionsParams, options?: RequestOptions): APIPromise { - return this.sessionClient.browsers.loadExtensions(this.sessionId, body, this.opt(options)); - } -} diff --git a/src/lib/kernel-browser-session.ts b/src/lib/kernel-browser-session.ts deleted file mode 100644 index 21f47a8e..00000000 --- a/src/lib/kernel-browser-session.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { HeadersInit, RequestInfo, RequestInit } from '../internal/builtin-types'; -import { Kernel } from '../client'; -import { KernelError } from '../core/error'; -import type { FinalRequestOptions, RequestOptions } from '../internal/request-options'; -import type { - BrowserCreateResponse, - BrowserListResponse, - BrowserRetrieveResponse, -} from '../resources/browsers/browsers'; -import { buildHeaders } from '../internal/headers'; -import { GeneratedBrowserSessionBindings } from './generated/browser-session-bindings'; -import { - resolveBrowserTransport, - type KernelBrowserLike, - type ResolvedBrowserTransport, -} from './browser-transport'; - -export type KernelBrowserInput = - | KernelBrowserLike - | BrowserCreateResponse - | BrowserRetrieveResponse - | BrowserListResponse; - -export interface BrowserFetchInit extends RequestInit { - /** Passed to the upstream /curl/raw handler as timeout_ms when set. */ - timeout_ms?: number; -} - -/** - * Browser-scoped API view: subresources omit the browser session id and are routed - * through {@link BrowserCreateResponse.base_url} (browser session HTTP base URL for - * the browser VM edge) with jwt query authentication. - */ -export class KernelBrowserSession extends GeneratedBrowserSessionBindings { - protected override readonly sessionClient: Kernel; - private readonly transport: ResolvedBrowserTransport; - - constructor(kernel: Kernel, browser: KernelBrowserInput) { - const transport = resolveBrowserTransport(browser); - const sessionId = transport.sessionId; - const baseURL = transport.defaultBaseURL; - if (!baseURL) { - throw new KernelError( - 'kernel.forBrowser requires browser.base_url from the Kernel API. Create or retrieve the browser and pass a response that includes base_url before using the browser session client.', - ); - } - - const sessionClient = createBrowserSessionKernel(kernel, { - ...transport, - defaultBaseURL: baseURL, - }); - super(sessionClient, sessionId); - this.sessionClient = sessionClient; - this.transport = transport; - } - - /** - * Issue an HTTP request through the browser VM network stack (Chrome), returning - * the upstream response as a standard Fetch {@link Response}. Implemented via - * the browser session base URL and POST /curl/raw (internal). - */ - async fetch(input: RequestInfo | URL, init?: BrowserFetchInit): Promise { - if (!this.transport.jwt) { - throw new KernelError( - 'browser.fetch requires a browser session jwt (parsed from cdp_ws_url, or pass jwt on the browser object).', - ); - } - - const { url: targetUrl, method, headers, body, signal, duplex, timeout_ms } = splitFetchArgs(input, init); - assertHttpTargetUrl(targetUrl); - - const query: Record = { - url: targetUrl, - jwt: this.transport.jwt, - }; - if (timeout_ms !== undefined) { - query['timeout_ms'] = timeout_ms; - } - - const accept = headers.get('accept'); - const headerPairs = headersToRequestOptionsHeaders(headers); - - const methodLower = method.toLowerCase(); - const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); - if (!allowed.has(methodLower)) { - throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`); - } - - return this.sessionClient - .request({ - method: methodLower, - path: '/curl/raw', - query, - body: body as RequestOptions['body'], - headers: buildHeaders([accept ? { Accept: accept } : { Accept: '*/*' }, headerPairs]), - signal: signal ?? null, - ...(duplex ? { fetchOptions: { duplex } as RequestOptions['fetchOptions'] } : {}), - __binaryResponse: true, - } as any) - .asResponse(); - } -} - -function createBrowserSessionKernel( - parent: Kernel, - transport: ResolvedBrowserTransport & { defaultBaseURL: string }, -): Kernel { - const defaultQuery = - transport.jwt ? - { - ...(((parent as any)._options?.defaultQuery as Record | undefined) ?? {}), - jwt: transport.jwt, - } - : (parent as any)._options?.defaultQuery ?? undefined; - - const sessionClient = parent.withOptions({ - baseURL: transport.defaultBaseURL, - defaultQuery: defaultQuery as Record | undefined, - }) as Kernel; - - const originalPrepareOptions = ( - (sessionClient as any).prepareOptions as ((options: FinalRequestOptions) => Promise) | undefined - )?.bind(sessionClient); - - (sessionClient as any).authHeaders = async () => undefined; - (sessionClient as any).prepareOptions = async (options: FinalRequestOptions) => { - if (originalPrepareOptions) { - await originalPrepareOptions(options); - } - const prefix = `/browsers/${transport.sessionId}/`; - if (options.path.startsWith(prefix)) { - const rest = options.path.slice(prefix.length); - options.path = rest.startsWith('/') ? rest : `/${rest}`; - } - }; - - return sessionClient; -} - -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 merged = new Headers(input.headers); - if (init?.headers) { - const extra = new Headers(init.headers as HeadersInit); - extra.forEach((value, key) => { - merged.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: merged, - }; - const mergedBody = init?.body ?? input.body; - if (mergedBody !== undefined && mergedBody !== null) { - out.body = mergedBody; - } - const mergedSignal = init?.signal ?? input.signal; - if (mergedSignal !== undefined) { - out.signal = mergedSignal; - } - if (init?.duplex !== undefined) { - out.duplex = init.duplex; - } - if (timeoutFromInit !== undefined) { - out.timeout_ms = timeoutFromInit; - } - return out; - } - - const url = input instanceof URL ? input.href : String(input); - const method = (init?.method ?? 'GET').toUpperCase(); - const headers = new Headers(init?.headers as HeadersInit | undefined); - const out: { - url: string; - method: string; - headers: Headers; - body?: RequestInit['body']; - signal?: AbortSignal | null; - duplex?: RequestInit['duplex']; - timeout_ms?: number; - } = { url, method, 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 assertHttpTargetUrl(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) => { - const lower = key.toLowerCase(); - if ( - lower === 'accept' || - lower === 'content-length' || - lower === 'connection' || - lower === 'keep-alive' || - lower === 'proxy-authenticate' || - lower === 'proxy-authorization' || - lower === 'te' || - lower === 'trailers' || - lower === 'transfer-encoding' || - lower === 'upgrade' - ) { - return; - } - out[key] = value; - }); - return out; -} diff --git a/src/resources/browsers/browsers.ts b/src/resources/browsers/browsers.ts index dc2ff806..e0a1d5bd 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-routing'; /** * 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..d26600de --- /dev/null +++ b/tests/lib/browser-routing.test.ts @@ -0,0 +1,130 @@ +import Kernel from '@onkernel/sdk'; + +describe('browser routing', () => { + const normalizeURL = (input: string | URL | Request) => + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + + test('warms cache from browser responses and routes allowlisted subresources directly to the VM', async () => { + const calls: Array<{ url: string; headers: Headers }> = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + browserRouting: { + enabled: true, + directToVMSubresources: ['process', 'curl'], + }, + 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 () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + browserRouting: { + enabled: true, + directToVMSubresources: ['computer'], + }, + 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 () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + browserRouting: { + enabled: true, + directToVMSubresources: ['process'], + }, + 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('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.prime({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?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/); + }); +}); diff --git a/tests/lib/browser-transport.test.ts b/tests/lib/browser-transport.test.ts index 2eba5215..d89011d4 100644 --- a/tests/lib/browser-transport.test.ts +++ b/tests/lib/browser-transport.test.ts @@ -1,8 +1,4 @@ -import { - mergeBrowserScopedRequestOptions, - parseJwtFromCdpWsUrl, - resolveBrowserTransport, -} from '../../src/lib/browser-transport'; +import { parseJwtFromCdpWsUrl, resolveBrowserTransport } from '../../src/lib/browser-transport'; describe('browser transport', () => { test('parseJwtFromCdpWsUrl reads jwt query param', () => { @@ -30,19 +26,4 @@ describe('browser transport', () => { }); expect(t.jwt).toBe('fromcdp'); }); - - test('mergeBrowserScopedRequestOptions injects jwt into query', () => { - const merged = mergeBrowserScopedRequestOptions( - { sessionId: 's', defaultBaseURL: 'https://m/k', jwt: 'j' }, - { query: { a: '1' } }, - ); - expect(merged?.defaultBaseURL).toBe('https://m/k'); - expect(merged?.query).toEqual({ a: '1', jwt: 'j' }); - }); - - test('mergeBrowserScopedRequestOptions is noop without browser session base URL', () => { - const opts = { query: { a: '1' } }; - const merged = mergeBrowserScopedRequestOptions({ sessionId: 's' }, opts); - expect(merged).toBe(opts); - }); }); diff --git a/tests/lib/kernel-browser-session.test.ts b/tests/lib/kernel-browser-session.test.ts deleted file mode 100644 index 5291ab01..00000000 --- a/tests/lib/kernel-browser-session.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import Kernel from '@onkernel/sdk'; -import { KernelBrowserSession } from '../../src/lib/kernel-browser-session'; - -describe('KernelBrowserSession', () => { - test('throws when base_url is missing', () => { - const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); - expect( - () => - new KernelBrowserSession(kernel, { - session_id: 'abc', - cdp_ws_url: 'wss://x/browser/cdp?jwt=j', - }), - ).toThrow(/base_url/); - }); - - test('throws when jwt cannot be resolved', async () => { - const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); - const browser = new KernelBrowserSession(kernel, { - session_id: 'abc', - base_url: 'https://vm.browser-session.test/browser/kernel', - cdp_ws_url: 'wss://x/browser/cdp', - }); - await expect(browser.fetch('https://example.com')).rejects.toThrow(/jwt/); - }); - - test('issues /curl/raw against browser session base URL with jwt query', async () => { - const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; - const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); - (kernel as any).fetch = async (url: string, init?: RequestInit) => { - fetchCalls.push({ url, init }); - return new Response('ok', { - status: 200, - headers: { 'content-type': 'text/plain' }, - }); - }; - - const browser = new KernelBrowserSession(kernel, { - session_id: 'abc', - base_url: 'https://vm.browser-session.test/browser/kernel', - cdp_ws_url: 'wss://x/browser/cdp?jwt=tok', - }); - - const res = await browser.fetch('https://example.com/hello', { - method: 'GET', - headers: { 'X-Test': '1' }, - }); - expect(res.status).toBe(200); - expect(fetchCalls.length).toBe(1); - const call = fetchCalls[0]!; - expect(call.url).toContain('https://vm.browser-session.test/browser/kernel/curl/raw?'); - expect(call.url).toContain('url=https%3A%2F%2Fexample.com%2Fhello'); - expect(call.url).toContain('jwt=tok'); - expect((call.init?.headers as Headers).get('authorization')).toBeNull(); - }); - - test('rewrites browser subresource paths through browser session base URL', async () => { - const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; - const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); - (kernel as any).fetch = async (url: string, init?: RequestInit) => { - fetchCalls.push({ url, init }); - return new Response('', { - status: 200, - headers: { 'content-type': '*/*' }, - }); - }; - - const browser = new KernelBrowserSession(kernel, { - session_id: 'abc', - base_url: 'https://vm.browser-session.test/browser/kernel', - cdp_ws_url: 'wss://x/browser/cdp?jwt=tok', - }); - - await browser.computer.clickMouse({ x: 1, y: 2 }); - - expect(fetchCalls.length).toBe(1); - const call = fetchCalls[0]!; - expect(call.url).toContain('https://vm.browser-session.test/browser/kernel/computer/click_mouse?'); - expect(call.url).toContain('jwt=tok'); - expect(call.url).not.toContain('/browsers/abc/'); - expect((call.init?.headers as Headers).get('authorization')).toBeNull(); - }); -}); From 20827054115bd07042359acc6a167c72ab8a581f Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 11:43:34 -0400 Subject: [PATCH 06/19] refactor: simplify browser routing cache Trim the node browser routing changes down to the cache/interceptor shape from PR #100 and remove the leftover browser-scoped example and priming surface. Made-with: Cursor --- examples/browser-routing.ts | 16 ++ examples/browser-scoped.ts | 26 --- src/index.ts | 1 - src/lib/browser-routing.ts | 279 ++++------------------------- src/resources/browsers/browsers.ts | 18 +- tests/lib/browser-routing.test.ts | 28 +-- 6 files changed, 68 insertions(+), 300 deletions(-) create mode 100644 examples/browser-routing.ts delete mode 100644 examples/browser-scoped.ts diff --git a/examples/browser-routing.ts b/examples/browser-routing.ts new file mode 100644 index 00000000..127851dc --- /dev/null +++ b/examples/browser-routing.ts @@ -0,0 +1,16 @@ +import Kernel from '@onkernel/sdk'; + +async function main() { + const kernel = new Kernel({ + browserRouting: { + enabled: true, + directToVMSubresources: ['computer'], + }, + }); + + const browser = await kernel.browsers.create({}); + await kernel.browsers.computer.clickMouse(browser.session_id, { x: 10, y: 10 }); + await kernel.browsers.deleteByID(browser.session_id); +} + +void main(); diff --git a/examples/browser-scoped.ts b/examples/browser-scoped.ts deleted file mode 100644 index 52146c9f..00000000 --- a/examples/browser-scoped.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Browser routing: keep the standard browser resource surface while routing - * allowlisted subresources and raw HTTP directly to the browser VM. - */ -import Kernel from '@onkernel/sdk'; - -async function main() { - const kernel = new Kernel({ - browserRouting: { - enabled: true, - directToVMSubresources: ['computer'], - }, - }); - - const created = await kernel.browsers.create({}); - kernel.browserRouteCache.prime(created); - - await kernel.browsers.computer.clickMouse(created.session_id, { x: 10, y: 10 }); - - const page = await kernel.browsers.fetch(created.session_id, 'https://example.com', { method: 'GET' }); - console.log('status', page.status); - - await kernel.browsers.deleteByID(created.session_id); -} - -void main(); diff --git a/src/index.ts b/src/index.ts index 5692ba6f..94bd28d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,6 @@ export { APIPromise } from './core/api-promise'; export { Kernel, type ClientOptions } from './client'; export { BrowserRouteCache, - type BrowserFetchInit, type BrowserRoute, type BrowserRoutingOptions, } from './lib/browser-routing'; diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index ff5c2b86..c2255342 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -1,20 +1,10 @@ -import type { RequestInfo, RequestInit } from '../internal/builtin-types'; -import { KernelError } from '../core/error'; -import { buildHeaders } from '../internal/headers'; import type { Fetch } from '../internal/builtin-types'; -import type { FinalRequestOptions, RequestOptions } from '../internal/request-options'; -import type { HTTPMethod } from '../internal/types'; import { parseJwtFromCdpWsUrl } from './browser-transport'; -import type { Kernel } from '../client'; - -export interface BrowserFetchInit extends RequestInit { - timeout_ms?: number; -} export type BrowserRoute = { sessionId: string; baseURL: string; - jwt?: string | undefined; + jwt: string; }; export interface BrowserRoutingOptions { @@ -31,7 +21,11 @@ export class BrowserRouteCache { } set(route: BrowserRoute): void { - this.entries.set(route.sessionId, normalizeRoute(route)); + this.entries.set(route.sessionId, { + sessionId: route.sessionId.trim(), + baseURL: route.baseURL.trim(), + jwt: route.jwt.trim(), + }); } delete(sessionId: string): void { @@ -41,19 +35,6 @@ export class BrowserRouteCache { clear(): void { this.entries.clear(); } - - prime(browser: unknown): BrowserRoute | undefined { - const route = browserRouteFromValue(browser); - if (!route) { - return undefined; - } - this.set(route); - return route; - } - - values(): BrowserRoute[] { - return [...this.entries.values()]; - } } export function createRoutingFetch( @@ -72,73 +53,13 @@ export function createRoutingFetch( const apiOrigin = new URL(apiBaseURL).origin; return async (input, init) => { - const request = new Request(input as RequestInfo, init); - const match = matchDirectToVMRequest(request.url, apiOrigin, allowed); - - const response = match ? await fetchDirectToVM(innerFetch, request, match, cache) : await innerFetch(input, init); - await sniffAndPrimeCache(response, cache); + const request = new Request(input, init); + const response = await routeRequest(innerFetch, request, apiOrigin, allowed, cache); + await sniffAndPopulateCache(response, cache); return response; }; } -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`, - ); - } - if (!route.jwt) { - throw new KernelError(`browser.fetch requires a browser session jwt for ${sessionId}`); - } - - 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 methodLower = normalizeMethod(method); - - const requestOptions: FinalRequestOptions = { - method: methodLower, - 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 normalizeRoute(route: BrowserRoute): BrowserRoute { - return { - sessionId: route.sessionId.trim(), - baseURL: route.baseURL.trim(), - jwt: route.jwt?.trim() || undefined, - }; -} - function browserRouteFromValue(value: unknown): BrowserRoute | undefined { if (!value || typeof value !== 'object') { return undefined; @@ -153,28 +74,31 @@ function browserRouteFromValue(value: unknown): BrowserRoute | 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: explicitJWT || parseJwtFromCdpWsUrl(cdpWsURL) || undefined, + jwt, }; } -async function sniffAndPrimeCache(response: Response, cache: BrowserRouteCache): Promise { +async function sniffAndPopulateCache(response: Response, cache: BrowserRouteCache): Promise { const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; if (!contentType.includes('application/json')) { return; } try { - const body = await response.clone().json(); - primeRoutesFromJSON(body, cache); + populateCache(await response.clone().json(), cache); } catch { - // Ignore malformed or non-JSON bodies returned with a JSON content type. + // Ignore malformed JSON in routing cache population. } } -function primeRoutesFromJSON(value: unknown, cache: BrowserRouteCache): void { +function populateCache(value: unknown, cache: BrowserRouteCache): void { const route = browserRouteFromValue(value); if (route) { cache.set(route); @@ -182,7 +106,7 @@ function primeRoutesFromJSON(value: unknown, cache: BrowserRouteCache): void { if (Array.isArray(value)) { for (const item of value) { - primeRoutesFromJSON(item, cache); + populateCache(item, cache); } return; } @@ -193,58 +117,45 @@ function primeRoutesFromJSON(value: unknown, cache: BrowserRouteCache): void { for (const child of Object.values(value as Record)) { if (typeof child === 'object' && child !== null) { - primeRoutesFromJSON(child, cache); + populateCache(child, cache); } } } -function matchDirectToVMRequest( - rawURL: string, +async function routeRequest( + innerFetch: Fetch, + request: Request, apiOrigin: string, allowed: ReadonlySet, -): { sessionId: string; subresource: string; rest: string } | undefined { - const url = new URL(rawURL); + cache: BrowserRouteCache, +): Promise { + const url = new URL(request.url); if (url.origin !== apiOrigin) { - return undefined; + return innerFetch(request); } const match = url.pathname.match(/^\/(?:v\d+\/)?browsers\/([^/]+)\/([^/]+)(\/.*)?$/); if (!match) { - return undefined; + return innerFetch(request); } const sessionId = decodeURIComponent(match[1] ?? ''); const subresource = match[2] ?? ''; if (!sessionId || !allowed.has(subresource)) { - return undefined; + return innerFetch(request); } - - return { - sessionId, - subresource, - rest: match[3] ?? '', - }; -} - -async function fetchDirectToVM( - innerFetch: Fetch, - request: Request, - match: { sessionId: string; subresource: string; rest: string }, - cache: BrowserRouteCache, -): Promise { - const route = cache.get(match.sessionId); - if (!route) { + const route = cache.get(sessionId); + if (route === undefined) { return innerFetch(request); } - const target = new URL(joinURL(route.baseURL, `/${match.subresource}${match.rest}`)); - const current = new URL(request.url); - current.searchParams.forEach((value, key) => { + const target = new URL(joinURL(route.baseURL, `/${subresource}${match[3] ?? ''}`)); + url.searchParams.forEach((value, key) => { if (key !== 'jwt') { target.searchParams.append(key, value); } }); - if (route.jwt && !target.searchParams.get('jwt')) { + if (!target.searchParams.get('jwt')) { target.searchParams.set('jwt', route.jwt); } @@ -269,125 +180,3 @@ function joinURL(baseURL: string, path: string): string { return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; } -function normalizeMethod(method: string): HTTPMethod { - const methodLower = method.toLowerCase(); - const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); - 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 merged = new Headers(input.headers); - if (init?.headers) { - const extra = new Headers(init.headers); - extra.forEach((value, key) => { - merged.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: merged, - }; - const mergedBody = init?.body ?? input.body; - if (mergedBody !== undefined && mergedBody !== null) { - out.body = mergedBody; - } - const mergedSignal = init?.signal ?? input.signal; - if (mergedSignal !== undefined) { - out.signal = mergedSignal; - } - if (init?.duplex !== undefined) { - out.duplex = init.duplex; - } - if (timeoutFromInit !== undefined) { - out.timeout_ms = timeoutFromInit; - } - return out; - } - - const url = input instanceof URL ? input.href : String(input); - const method = (init?.method ?? 'GET').toUpperCase(); - const headers = new Headers(init?.headers); - const out: { - url: string; - method: string; - headers: Headers; - body?: RequestInit['body']; - signal?: AbortSignal | null; - duplex?: RequestInit['duplex']; - timeout_ms?: number; - } = { url, method, 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) => { - const lower = key.toLowerCase(); - if ( - lower === 'accept' || - lower === 'content-length' || - lower === 'connection' || - lower === 'keep-alive' || - lower === 'proxy-authenticate' || - lower === 'proxy-authorization' || - lower === 'te' || - lower === 'trailers' || - lower === 'transfer-encoding' || - lower === 'upgrade' - ) { - return; - } - out[key] = value; - }); - return out; -} diff --git a/src/resources/browsers/browsers.ts b/src/resources/browsers/browsers.ts index e0a1d5bd..61873f31 100644 --- a/src/resources/browsers/browsers.ts +++ b/src/resources/browsers/browsers.ts @@ -1,6 +1,5 @@ // 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'; @@ -76,7 +75,6 @@ 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-routing'; /** * Create and manage browser sessions. @@ -186,14 +184,6 @@ 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 * @@ -345,7 +335,7 @@ export interface BrowserCreateResponse { webdriver_ws_url: string; /** - * HTTP base URL for routing browser subresource requests to this session's browser VM. + * Metro-API HTTP base URL for this browser session. */ base_url?: string; @@ -451,7 +441,7 @@ export interface BrowserRetrieveResponse { webdriver_ws_url: string; /** - * HTTP base URL for routing browser subresource requests to this session's browser VM. + * Metro-API HTTP base URL for this browser session. */ base_url?: string; @@ -557,7 +547,7 @@ export interface BrowserUpdateResponse { webdriver_ws_url: string; /** - * HTTP base URL for routing browser subresource requests to this session's browser VM. + * Metro-API HTTP base URL for this browser session. */ base_url?: string; @@ -663,7 +653,7 @@ export interface BrowserListResponse { webdriver_ws_url: string; /** - * HTTP base URL for routing browser subresource requests to this session's browser VM. + * Metro-API HTTP base URL for this browser session. */ base_url?: string; diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index d26600de..f389310f 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -104,27 +104,27 @@ describe('browser routing', () => { ]); }); - test('browser.fetch uses the shared cache and fails clearly on cache miss', async () => { - const calls: string[] = []; + test('ignores browser responses that do not include a usable jwt', async () => { const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/', + browserRouting: { + enabled: true, + directToVMSubresources: ['process'], + }, fetch: async (input) => { const url = normalizeURL(input); - calls.push(url); - return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }); + 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: '' }); }, }); - kernel.browserRouteCache.prime({ - session_id: 'sess-1', - base_url: 'http://browser-session.test/browser/kernel', - cdp_ws_url: 'wss://browser-session.test/browser/cdp?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/); + await kernel.browsers.create(); + expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined(); }); }); From 7030d969217535fd7e29215ef1e45e8625e6c3e4 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 12:57:08 -0400 Subject: [PATCH 07/19] refactor: rename browser routing subresources config Shorten the browserRouting allowlist field to subresources so the direct-to-VM configuration reads more cleanly without changing behavior. Made-with: Cursor --- examples/browser-routing.ts | 2 +- src/client.ts | 2 +- src/lib/browser-routing.ts | 8 ++++---- tests/lib/browser-routing.test.ts | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/browser-routing.ts b/examples/browser-routing.ts index 127851dc..26e9850b 100644 --- a/examples/browser-routing.ts +++ b/examples/browser-routing.ts @@ -4,7 +4,7 @@ async function main() { const kernel = new Kernel({ browserRouting: { enabled: true, - directToVMSubresources: ['computer'], + subresources: ['computer'], }, }); diff --git a/src/client.ts b/src/client.ts index 27164d10..5e960fa0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -330,7 +330,7 @@ export class Kernel { options.browserRouting?.enabled ? createRoutingFetch(this.rawFetch, { apiBaseURL: this.baseURL, - directToVMSubresources: options.browserRouting.directToVMSubresources ?? [], + subresources: options.browserRouting.subresources ?? [], cache: this.browserRouteCache, }) : this.rawFetch; diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index c2255342..2633ba4b 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -9,7 +9,7 @@ export type BrowserRoute = { export interface BrowserRoutingOptions { enabled?: boolean; - directToVMSubresources?: string[] | undefined; + subresources?: string[] | undefined; cache?: BrowserRouteCache | undefined; } @@ -41,15 +41,15 @@ export function createRoutingFetch( innerFetch: Fetch, { apiBaseURL, - directToVMSubresources, + subresources, cache, }: { apiBaseURL: string; - directToVMSubresources: Iterable; + subresources: Iterable; cache: BrowserRouteCache; }, ): Fetch { - const allowed = new Set([...directToVMSubresources].map((value) => value.trim()).filter(Boolean)); + const allowed = new Set([...subresources].map((value) => value.trim()).filter(Boolean)); const apiOrigin = new URL(apiBaseURL).origin; return async (input, init) => { diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index f389310f..6acf0149 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -11,7 +11,7 @@ describe('browser routing', () => { baseURL: 'https://api.example/', browserRouting: { enabled: true, - directToVMSubresources: ['process', 'curl'], + subresources: ['process', 'curl'], }, fetch: async (input, init?: RequestInit) => { const url = normalizeURL(input); @@ -48,7 +48,7 @@ describe('browser routing', () => { baseURL: 'https://api.example/', browserRouting: { enabled: true, - directToVMSubresources: ['computer'], + subresources: ['computer'], }, fetch: async (input) => { const url = normalizeURL(input); @@ -77,7 +77,7 @@ describe('browser routing', () => { baseURL: 'https://api.example/', browserRouting: { enabled: true, - directToVMSubresources: ['process'], + subresources: ['process'], }, fetch: async (input) => { const url = normalizeURL(input); @@ -110,7 +110,7 @@ describe('browser routing', () => { baseURL: 'https://api.example/', browserRouting: { enabled: true, - directToVMSubresources: ['process'], + subresources: ['process'], }, fetch: async (input) => { const url = normalizeURL(input); From 0d9ddced05e9b030efd055ccf9fc75080ec6dcec Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 13:05:09 -0400 Subject: [PATCH 08/19] docs: restore raw http example in browser routing demo Keep the node browser-routing example showing both direct subresource routing and the cache-backed /curl/raw path. Made-with: Cursor --- examples/browser-routing.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/browser-routing.ts b/examples/browser-routing.ts index 26e9850b..83777dfc 100644 --- a/examples/browser-routing.ts +++ b/examples/browser-routing.ts @@ -4,12 +4,20 @@ async function main() { const kernel = new Kernel({ browserRouting: { enabled: true, - subresources: ['computer'], + subresources: ['computer', 'curl'], }, }); const browser = await kernel.browsers.create({}); await kernel.browsers.computer.clickMouse(browser.session_id, { x: 10, y: 10 }); + + const response = await kernel + .get(`/browsers/${browser.session_id}/curl/raw`, { + query: { url: 'https://example.com' }, + }) + .asResponse(); + console.log('status', response.status); + await kernel.browsers.deleteByID(browser.session_id); } From b09434ec0642ec166a749beccc985510ed3a4723 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 13:11:16 -0400 Subject: [PATCH 09/19] feat: restore node browser fetch helper Bring back the cache-backed browser fetch helper so raw HTTP stays on the SDK's language-native surface instead of falling through to manual /curl/raw requests. Made-with: Cursor --- examples/browser-routing.ts | 9 +- src/index.ts | 1 + src/lib/browser-routing.ts | 179 ++++++++++++++++++++++++++++- src/resources/browsers/browsers.ts | 10 ++ tests/lib/browser-routing.test.ts | 24 ++++ 5 files changed, 215 insertions(+), 8 deletions(-) diff --git a/examples/browser-routing.ts b/examples/browser-routing.ts index 83777dfc..34ac6b89 100644 --- a/examples/browser-routing.ts +++ b/examples/browser-routing.ts @@ -4,18 +4,13 @@ async function main() { const kernel = new Kernel({ browserRouting: { enabled: true, - subresources: ['computer', 'curl'], + subresources: ['computer'], }, }); const browser = await kernel.browsers.create({}); await kernel.browsers.computer.clickMouse(browser.session_id, { x: 10, y: 10 }); - - const response = await kernel - .get(`/browsers/${browser.session_id}/curl/raw`, { - query: { url: 'https://example.com' }, - }) - .asResponse(); + 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); diff --git a/src/index.ts b/src/index.ts index 94bd28d3..5692ba6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export { APIPromise } from './core/api-promise'; export { Kernel, type ClientOptions } from './client'; export { BrowserRouteCache, + type BrowserFetchInit, type BrowserRoute, type BrowserRoutingOptions, } from './lib/browser-routing'; diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index 2633ba4b..3033dc0a 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -1,5 +1,10 @@ -import type { Fetch } from '../internal/builtin-types'; +import type { RequestInfo, RequestInit, Fetch } 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 { parseJwtFromCdpWsUrl } from './browser-transport'; +import type { Kernel } from '../client'; export type BrowserRoute = { sessionId: string; @@ -13,6 +18,10 @@ export interface BrowserRoutingOptions { cache?: BrowserRouteCache | undefined; } +export interface BrowserFetchInit extends RequestInit { + timeout_ms?: number; +} + export class BrowserRouteCache { private entries = new Map(); @@ -60,6 +69,51 @@ export function createRoutingFetch( }; } +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 browserRouteFromValue(value: unknown): BrowserRoute | undefined { if (!value || typeof value !== 'object') { return undefined; @@ -180,3 +234,126 @@ function joinURL(baseURL: string, path: string): string { return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; } +function normalizeMethod(method: string): HTTPMethod { + const methodLower = method.toLowerCase(); + const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); + 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 merged = new Headers(input.headers); + if (init?.headers) { + const extra = new Headers(init.headers); + extra.forEach((value, key) => { + merged.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: merged, + }; + const mergedBody = init?.body ?? input.body; + if (mergedBody !== undefined && mergedBody !== null) { + out.body = mergedBody; + } + const mergedSignal = init?.signal ?? input.signal; + if (mergedSignal !== undefined) { + out.signal = mergedSignal; + } + if (init?.duplex !== undefined) { + out.duplex = init.duplex; + } + if (timeoutFromInit !== undefined) { + out.timeout_ms = timeoutFromInit; + } + return out; + } + + const url = input instanceof URL ? input.href : String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + const headers = new Headers(init?.headers); + const out: { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; + } = { url, method, 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) => { + const lower = key.toLowerCase(); + if ( + lower === 'accept' || + lower === 'content-length' || + lower === 'connection' || + lower === 'keep-alive' || + lower === 'proxy-authenticate' || + lower === 'proxy-authorization' || + lower === 'te' || + lower === 'trailers' || + lower === 'transfer-encoding' || + lower === 'upgrade' + ) { + return; + } + out[key] = value; + }); + return out; +} + diff --git a/src/resources/browsers/browsers.ts b/src/resources/browsers/browsers.ts index 61873f31..2eee94c5 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-routing'; /** * 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 index 6acf0149..dac40b6b 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -127,4 +127,28 @@ describe('browser routing', () => { 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/); + }); }); From 2f1638684e9e687f26c24bd7e2d54bb335810ef8 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 13:19:12 -0400 Subject: [PATCH 10/19] fix: drop node browser routing branch churn Remove the unnecessary generated resource and dependency diffs from the node branch and keep BrowserRouteCache.set() as a direct assignment without trimming user input. Made-with: Cursor --- package.json | 1 - src/lib/browser-routing.ts | 6 +--- src/resources/browser-pools.ts | 2 +- src/resources/invocations.ts | 2 +- yarn.lock | 66 +--------------------------------- 5 files changed, 4 insertions(+), 73 deletions(-) diff --git a/package.json b/package.json index 961a11ec..7eabcc83 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "prettier": "^3.0.0", "publint": "^0.2.12", "ts-jest": "^29.1.0", - "ts-morph": "^28.0.0", "ts-node": "^10.5.0", "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index 3033dc0a..e210b381 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -30,11 +30,7 @@ export class BrowserRouteCache { } set(route: BrowserRoute): void { - this.entries.set(route.sessionId, { - sessionId: route.sessionId.trim(), - baseURL: route.baseURL.trim(), - jwt: route.jwt.trim(), - }); + this.entries.set(route.sessionId, route); } delete(sessionId: string): void { diff --git a/src/resources/browser-pools.ts b/src/resources/browser-pools.ts index 76315b2b..f587ded7 100644 --- a/src/resources/browser-pools.ts +++ b/src/resources/browser-pools.ts @@ -306,7 +306,7 @@ export interface BrowserPoolAcquireResponse { webdriver_ws_url: string; /** - * HTTP base URL for routing browser subresource requests to this session's browser VM. + * Metro-API HTTP base URL for this browser session. */ base_url?: string; diff --git a/src/resources/invocations.ts b/src/resources/invocations.ts index 011637c8..430751b8 100644 --- a/src/resources/invocations.ts +++ b/src/resources/invocations.ts @@ -454,7 +454,7 @@ export namespace InvocationListBrowsersResponse { webdriver_ws_url: string; /** - * HTTP base URL for routing browser subresource requests to this session's browser VM. + * Metro-API HTTP base URL for this browser session. */ base_url?: string; diff --git a/yarn.lock b/yarn.lock index 84803891..f6eae3cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -828,15 +828,6 @@ dependencies: "@swc/counter" "^0.1.3" -"@ts-morph/common@~0.29.0": - version "0.29.0" - resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.29.0.tgz#bb1ed737f309c8270bb2e92207066343c1302ae2" - integrity sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg== - dependencies: - minimatch "^10.0.1" - path-browserify "^1.0.1" - tinyglobby "^0.2.14" - "@tsconfig/node10@^1.0.7": version "1.0.8" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" @@ -890,13 +881,6 @@ dependencies: "@babel/types" "^7.20.7" -"@types/busboy@^1.5.4": - version "1.5.4" - resolved "https://registry.yarnpkg.com/@types/busboy/-/busboy-1.5.4.tgz#0038c31102ca90f2a7f0d8bc27ee5ebf1088e230" - integrity sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw== - dependencies: - "@types/node" "*" - "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" @@ -1279,13 +1263,6 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -busboy@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" - integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== - dependencies: - streamsearch "^1.1.0" - callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1388,11 +1365,6 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== -code-block-writer@^13.0.3: - version "13.0.3" - resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-13.0.3.tgz#90f8a84763a5012da7af61319dd638655ae90b5b" - integrity sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg== - collect-v8-coverage@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" @@ -1742,11 +1714,6 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" -fdir@^6.5.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" - integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== - fflate@^0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" @@ -2620,7 +2587,7 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimatch@^10.0.1, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1, minimatch@^9.0.4, minimatch@^9.0.5: +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1, minimatch@^9.0.4, minimatch@^9.0.5: version "9.0.9" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== @@ -2827,11 +2794,6 @@ parse5@^6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== -path-browserify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" - integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -2862,11 +2824,6 @@ picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" - integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== - pirates@^4.0.4: version "4.0.6" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" @@ -3097,11 +3054,6 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" -streamsearch@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" - integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== - string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -3222,14 +3174,6 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" -tinyglobby@^0.2.14: - version "0.2.16" - resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" - integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== - dependencies: - fdir "^6.5.0" - picomatch "^4.0.4" - tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -3261,14 +3205,6 @@ ts-jest@^29.1.0: semver "^7.5.3" yargs-parser "^21.0.1" -ts-morph@^28.0.0: - version "28.0.0" - resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-28.0.0.tgz#cd3ebdf89742a32b06ea7327df6445364ee26631" - integrity sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g== - dependencies: - "@ts-morph/common" "~0.29.0" - code-block-writer "^13.0.3" - ts-node@^10.5.0: version "10.7.0" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.7.0.tgz#35d503d0fab3e2baa672a0e94f4b40653c2463f5" From 7a56ab6c6c08697f1a6c4ddc104a9c6abab5bd98 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 13:32:27 -0400 Subject: [PATCH 11/19] fix: clean up node browser routing lint drift Tighten the browser routing files to the repo's formatter expectations so the node CI lint job passes cleanly again. Made-with: Cursor --- src/client.ts | 6 +----- src/lib/browser-routing.ts | 1 - src/lib/browser-transport.ts | 1 - tests/lib/browser-routing.test.ts | 11 +++++++++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/client.ts b/src/client.ts index 5e960fa0..14eaa686 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,11 +20,7 @@ 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, - createRoutingFetch, - type BrowserRoutingOptions, -} from './lib/browser-routing'; +import { BrowserRouteCache, createRoutingFetch, type BrowserRoutingOptions } from './lib/browser-routing'; import { BrowserPool, BrowserPoolAcquireParams, diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index e210b381..87c85795 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -352,4 +352,3 @@ function headersToRequestOptionsHeaders(headers: Headers): Record { - const normalizeURL = (input: string | URL | Request) => - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + 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 () => { const calls: Array<{ url: string; headers: Headers }> = []; From fdd3adf1cd0bbd669a6aa2cd053359256735d9a7 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 15:22:11 -0400 Subject: [PATCH 12/19] fix: simplify node browser routing helpers Split browser.fetch into its own helper, remove unused browser transport code, and simplify withOptions cache sharing so the routing layer stays easier to reason about. Made-with: Cursor --- src/client.ts | 21 ++-- src/index.ts | 8 +- src/lib/browser-fetch.ts | 183 ++++++++++++++++++++++++++++ src/lib/browser-routing.ts | 178 +-------------------------- src/lib/browser-transport.ts | 40 ------ src/resources/browsers/browsers.ts | 2 +- tests/lib/browser-transport.test.ts | 29 ----- 7 files changed, 199 insertions(+), 262 deletions(-) create mode 100644 src/lib/browser-fetch.ts delete mode 100644 src/lib/browser-transport.ts delete mode 100644 tests/lib/browser-transport.test.ts diff --git a/src/client.ts b/src/client.ts index 14eaa686..278baf9d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -342,21 +342,14 @@ export class Kernel { */ withOptions(options: Partial): this { const currentRouting = this._options.browserRouting; - const nextBrowserRouting = - options.browserRouting === undefined ? + const nextBrowserRouting = options.browserRouting === undefined ? currentRouting : options.browserRouting; + const sharedBrowserRouting = + nextBrowserRouting ? { - ...(currentRouting ?? {}), - cache: currentRouting?.cache ?? this.browserRouteCache, + ...nextBrowserRouting, + cache: nextBrowserRouting.cache ?? this.browserRouteCache, } - : options.browserRouting.enabled ? - { - ...options.browserRouting, - cache: options.browserRouting.cache ?? this.browserRouteCache, - } - : { - ...options.browserRouting, - cache: options.browserRouting.cache ?? this.browserRouteCache, - }; + : undefined; const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ ...this._options, @@ -370,7 +363,7 @@ export class Kernel { fetchOptions: this.fetchOptions, apiKey: this.apiKey, ...options, - browserRouting: nextBrowserRouting, + browserRouting: sharedBrowserRouting, }); return client; } diff --git a/src/index.ts b/src/index.ts index 5692ba6f..4b69a21d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,12 +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 { - BrowserRouteCache, - type BrowserFetchInit, - type BrowserRoute, - type BrowserRoutingOptions, -} from './lib/browser-routing'; +export { type BrowserFetchInit } from './lib/browser-fetch'; +export { BrowserRouteCache, type BrowserRoute, type BrowserRoutingOptions } 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..07bb6c47 --- /dev/null +++ b/src/lib/browser-fetch.ts @@ -0,0 +1,183 @@ +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 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', 'head', 'options']); + 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; +} + +function joinURL(baseURL: string, path: string): string { + return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; +} diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index 87c85795..aae3aa4b 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -1,10 +1,4 @@ -import type { RequestInfo, RequestInit, Fetch } 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 { parseJwtFromCdpWsUrl } from './browser-transport'; -import type { Kernel } from '../client'; +import type { Fetch } from '../internal/builtin-types'; export type BrowserRoute = { sessionId: string; @@ -18,10 +12,6 @@ export interface BrowserRoutingOptions { cache?: BrowserRouteCache | undefined; } -export interface BrowserFetchInit extends RequestInit { - timeout_ms?: number; -} - export class BrowserRouteCache { private entries = new Map(); @@ -65,51 +55,6 @@ export function createRoutingFetch( }; } -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 browserRouteFromValue(value: unknown): BrowserRoute | undefined { if (!value || typeof value !== 'object') { return undefined; @@ -230,125 +175,14 @@ function joinURL(baseURL: string, path: string): string { return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; } -function normalizeMethod(method: string): HTTPMethod { - const methodLower = method.toLowerCase(); - const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); - 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 merged = new Headers(input.headers); - if (init?.headers) { - const extra = new Headers(init.headers); - extra.forEach((value, key) => { - merged.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: merged, - }; - const mergedBody = init?.body ?? input.body; - if (mergedBody !== undefined && mergedBody !== null) { - out.body = mergedBody; - } - const mergedSignal = init?.signal ?? input.signal; - if (mergedSignal !== undefined) { - out.signal = mergedSignal; - } - if (init?.duplex !== undefined) { - out.duplex = init.duplex; - } - if (timeoutFromInit !== undefined) { - out.timeout_ms = timeoutFromInit; - } - return out; - } - - const url = input instanceof URL ? input.href : String(input); - const method = (init?.method ?? 'GET').toUpperCase(); - const headers = new Headers(init?.headers); - const out: { - url: string; - method: string; - headers: Headers; - body?: RequestInit['body']; - signal?: AbortSignal | null; - duplex?: RequestInit['duplex']; - timeout_ms?: number; - } = { url, method, 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; +function parseJwtFromCdpWsUrl(cdpWsUrl: string | undefined): string | undefined { + if (!cdpWsUrl) { + return undefined; } - return out; -} -function assertHTTPURL(url: string): void { - let parsed: URL; try { - parsed = new URL(url); + return new URL(cdpWsUrl).searchParams.get('jwt') ?? undefined; } 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}`); + return undefined; } } - -function headersToRequestOptionsHeaders(headers: Headers): Record { - const out: Record = {}; - headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if ( - lower === 'accept' || - lower === 'content-length' || - lower === 'connection' || - lower === 'keep-alive' || - lower === 'proxy-authenticate' || - lower === 'proxy-authorization' || - lower === 'te' || - lower === 'trailers' || - lower === 'transfer-encoding' || - lower === 'upgrade' - ) { - return; - } - out[key] = value; - }); - return out; -} diff --git a/src/lib/browser-transport.ts b/src/lib/browser-transport.ts deleted file mode 100644 index 5e7de49b..00000000 --- a/src/lib/browser-transport.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Resolved HTTP routing for a browser session. When {@link ResolvedBrowserTransport.defaultBaseURL} - * is set, requests use that browser session base URL plus a per-request jwt query param. - */ -export type ResolvedBrowserTransport = { - sessionId: string; - defaultBaseURL?: string | undefined; - jwt?: string | undefined; -}; - -export type KernelBrowserLike = { - session_id: string; - base_url?: string | null | undefined; - cdp_ws_url?: string | null | undefined; - /** When set, overrides jwt parsed from cdp_ws_url */ - jwt?: string | null | undefined; -}; - -export function parseJwtFromCdpWsUrl(cdpWsUrl: string | null | undefined): string | undefined { - if (!cdpWsUrl) { - return undefined; - } - try { - const u = new URL(cdpWsUrl); - const jwt = u.searchParams.get('jwt'); - return jwt ?? undefined; - } catch { - return undefined; - } -} - -export function resolveBrowserTransport(browser: KernelBrowserLike): ResolvedBrowserTransport { - const sessionId = browser.session_id; - const rawBase = browser.base_url?.trim(); - const defaultBaseURL = rawBase && rawBase.length > 0 ? rawBase : undefined; - const jwt = - (typeof browser.jwt === 'string' && browser.jwt.length > 0 ? browser.jwt : undefined) ?? - parseJwtFromCdpWsUrl(browser.cdp_ws_url ?? undefined); - return { sessionId, defaultBaseURL, jwt }; -} diff --git a/src/resources/browsers/browsers.ts b/src/resources/browsers/browsers.ts index 2eee94c5..0cad78e9 100644 --- a/src/resources/browsers/browsers.ts +++ b/src/resources/browsers/browsers.ts @@ -76,7 +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-routing'; +import { browserFetch, type BrowserFetchInit } from '../../lib/browser-fetch'; /** * Create and manage browser sessions. diff --git a/tests/lib/browser-transport.test.ts b/tests/lib/browser-transport.test.ts deleted file mode 100644 index d89011d4..00000000 --- a/tests/lib/browser-transport.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { parseJwtFromCdpWsUrl, resolveBrowserTransport } from '../../src/lib/browser-transport'; - -describe('browser transport', () => { - test('parseJwtFromCdpWsUrl reads jwt query param', () => { - const jwt = parseJwtFromCdpWsUrl('wss://browser-session.test/browser/cdp?jwt=abc%2B123&x=1'); - expect(jwt).toBe('abc+123'); - }); - - test('resolveBrowserTransport prefers explicit jwt', () => { - const t = resolveBrowserTransport({ - session_id: 'sess', - base_url: 'https://vm.browser-session.test/browser/kernel', - cdp_ws_url: 'wss://x/cdp?jwt=fromcdp', - jwt: 'explicit', - }); - expect(t.sessionId).toBe('sess'); - expect(t.defaultBaseURL).toBe('https://vm.browser-session.test/browser/kernel'); - expect(t.jwt).toBe('explicit'); - }); - - test('resolveBrowserTransport falls back to cdp_ws_url jwt', () => { - const t = resolveBrowserTransport({ - session_id: 'sess', - base_url: 'https://vm.browser-session.test/browser/kernel', - cdp_ws_url: 'wss://x/cdp?jwt=fromcdp', - }); - expect(t.jwt).toBe('fromcdp'); - }); -}); From 00c91ef1de5a18604fe6e66a57227a2cc794cdba Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 11:08:51 -0400 Subject: [PATCH 13/19] refactor: move node browser routing rollout to env Remove the public browser routing constructor knobs and read direct-to-VM subresource rollout from KERNEL_BROWSER_ROUTING_SUBRESOURCES instead, defaulting to curl while leaving browser.fetch cache-backed. --- examples/browser-routing.ts | 8 +- src/client.ts | 43 +++--- src/index.ts | 2 +- src/lib/browser-routing.ts | 39 ++++- tests/lib/browser-routing.test.ts | 234 +++++++++++++++++------------- 5 files changed, 182 insertions(+), 144 deletions(-) diff --git a/examples/browser-routing.ts b/examples/browser-routing.ts index 34ac6b89..0e4da960 100644 --- a/examples/browser-routing.ts +++ b/examples/browser-routing.ts @@ -1,15 +1,9 @@ import Kernel from '@onkernel/sdk'; async function main() { - const kernel = new Kernel({ - browserRouting: { - enabled: true, - subresources: ['computer'], - }, - }); + const kernel = new Kernel(); const browser = await kernel.browsers.create({}); - await kernel.browsers.computer.clickMouse(browser.session_id, { x: 10, y: 10 }); const response = await kernel.browsers.fetch(browser.session_id, 'https://example.com', { method: 'GET' }); console.log('status', response.status); diff --git a/src/client.ts b/src/client.ts index 278baf9d..636679ae 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,7 +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, createRoutingFetch, type BrowserRoutingOptions } from './lib/browser-routing'; +import { + BrowserRouteCache, + browserRoutingSubresourcesFromEnv, + createRoutingFetch, +} from './lib/browser-routing'; import { BrowserPool, BrowserPoolAcquireParams, @@ -195,11 +199,6 @@ export interface ClientOptions { */ fetch?: Fetch | undefined; - /** - * Configure direct-to-VM routing for browser subresource requests. - */ - browserRouting?: BrowserRoutingOptions | undefined; - /** * The maximum number of times that the client will retry a request in case of a * temporary failure, like a network error or a 5XX error from the server. @@ -321,15 +320,12 @@ export class Kernel { this.fetchOptions = options.fetchOptions; this.maxRetries = options.maxRetries ?? 2; this.rawFetch = options.fetch ?? Shims.getDefaultFetch(); - this.browserRouteCache = options.browserRouting?.cache ?? new BrowserRouteCache(); - this.fetch = - options.browserRouting?.enabled ? - createRoutingFetch(this.rawFetch, { - apiBaseURL: this.baseURL, - subresources: options.browserRouting.subresources ?? [], - cache: this.browserRouteCache, - }) - : this.rawFetch; + this.browserRouteCache = new BrowserRouteCache(); + this.fetch = createRoutingFetch(this.rawFetch, { + apiBaseURL: this.baseURL, + subresources: browserRoutingSubresourcesFromEnv(), + cache: this.browserRouteCache, + }); this.#encoder = Opts.FallbackEncoder; this._options = options; @@ -341,16 +337,6 @@ export class Kernel { * Create a new client instance re-using the same options given to the current client with optional overriding. */ withOptions(options: Partial): this { - const currentRouting = this._options.browserRouting; - const nextBrowserRouting = options.browserRouting === undefined ? currentRouting : options.browserRouting; - const sharedBrowserRouting = - nextBrowserRouting ? - { - ...nextBrowserRouting, - cache: nextBrowserRouting.cache ?? this.browserRouteCache, - } - : undefined; - const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ ...this._options, environment: options.environment ? options.environment : undefined, @@ -363,7 +349,12 @@ export class Kernel { fetchOptions: this.fetchOptions, apiKey: this.apiKey, ...options, - browserRouting: sharedBrowserRouting, + }); + 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 4b69a21d..81aa3351 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ 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, type BrowserRoutingOptions } from './lib/browser-routing'; +export { BrowserRouteCache, type BrowserRoute } from './lib/browser-routing'; export { PagePromise } from './core/pagination'; export { KernelError, diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index aae3aa4b..c719fdfc 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -6,12 +6,6 @@ export type BrowserRoute = { jwt: string; }; -export interface BrowserRoutingOptions { - enabled?: boolean; - subresources?: string[] | undefined; - cache?: BrowserRouteCache | undefined; -} - export class BrowserRouteCache { private entries = new Map(); @@ -32,6 +26,25 @@ export class BrowserRouteCache { } } +const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; +const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl']; + +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, { @@ -186,3 +199,17 @@ function parseJwtFromCdpWsUrl(cdpWsUrl: string | undefined): string | undefined 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/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index 4eb9edbd..5ecba622 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -1,6 +1,28 @@ import Kernel from '@onkernel/sdk'; +import { browserRoutingSubresourcesFromEnv } 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; @@ -12,127 +34,119 @@ describe('browser routing', () => { }; test('warms cache from browser responses and routes allowlisted subresources directly to the VM', async () => { - const calls: Array<{ url: string; headers: Headers }> = []; - const kernel = new Kernel({ - apiKey: 'k', - baseURL: 'https://api.example/', - browserRouting: { - enabled: true, - subresources: ['process', 'curl'], - }, - 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 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'] }); + 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(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(); }); - 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 () => { - const calls: string[] = []; - const kernel = new Kernel({ - apiKey: 'k', - baseURL: 'https://api.example/', - browserRouting: { - enabled: true, - subresources: ['computer'], - }, - 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 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' }); + 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'); + 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 () => { - const calls: string[] = []; - const kernel = new Kernel({ - apiKey: 'k', - baseURL: 'https://api.example/', - browserRouting: { - enabled: true, - subresources: ['process'], - }, - 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 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' }); + 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', - ]); + 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('ignores browser responses that do not include a usable jwt', async () => { - const kernel = new Kernel({ - apiKey: 'k', - baseURL: 'https://api.example/', - browserRouting: { - enabled: true, - subresources: ['process'], - }, - 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 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(); + 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 () => { @@ -158,4 +172,16 @@ describe('browser routing', () => { kernel.browserRouteCache.delete('sess-1'); await expect(kernel.browsers.fetch('sess-1', 'https://example.com/again')).rejects.toThrow(/route cache/); }); + + 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([]); + }); + }); }); From 9b242808521a9960ce48174af2e3e2ccb30a7a84 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 11:50:14 -0400 Subject: [PATCH 14/19] fix: preserve browser routing fetch options Keep the routing wrapper from stripping runtime-specific fetch init options when requests fall through or route directly to the VM, and share the browser fetch helpers so routed methods stay type-safe and covered by regression tests. Made-with: Cursor --- src/internal/types.ts | 8 +++- src/internal/utils/url.ts | 3 ++ src/lib/browser-fetch.ts | 5 +-- src/lib/browser-routing.ts | 73 ++++++++++++++++++++++++------- tests/lib/browser-routing.test.ts | 55 +++++++++++++++++++++++ 5 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 src/internal/utils/url.ts diff --git a/src/internal/types.ts b/src/internal/types.ts index b668dfc0..e6407bc0 100644 --- a/src/internal/types.ts +++ b/src/internal/types.ts @@ -1,7 +1,7 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. export type PromiseOrValue = T | Promise; -export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; +export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options'; export type KeysEnum = { [P in keyof Required]: true }; @@ -64,15 +64,19 @@ type OverloadedParameters = * [1]: https://www.typescriptlang.org/tsconfig/#typeAcquisition */ /** @ts-ignore For users with \@types/node */ +// prettier-ignore type UndiciTypesRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users with undici */ +// prettier-ignore type UndiciRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users with \@types/bun */ type BunRequestInit = globalThis.FetchRequestInit; /** @ts-ignore For users with node-fetch@2 */ +// prettier-ignore type NodeFetch2RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users with node-fetch@3, doesn't need file extension because types are at ./@types/index.d.ts */ -type NodeFetch3RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +// prettier-ignore +type NodeFetch3RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users who use Deno */ type FetchRequestInit = NonNullable[1]>; /* eslint-enable */ diff --git a/src/internal/utils/url.ts b/src/internal/utils/url.ts new file mode 100644 index 00000000..7dd54dd6 --- /dev/null +++ b/src/internal/utils/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/lib/browser-fetch.ts b/src/lib/browser-fetch.ts index 07bb6c47..6b6b2632 100644 --- a/src/lib/browser-fetch.ts +++ b/src/lib/browser-fetch.ts @@ -3,6 +3,7 @@ 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 '../internal/utils/url'; import type { Kernel } from '../client'; export interface BrowserFetchInit extends RequestInit { @@ -177,7 +178,3 @@ function headersToRequestOptionsHeaders(headers: Headers): Record { const request = new Request(input, init); - const response = await routeRequest(innerFetch, request, apiOrigin, allowed, cache); + const response = await routeRequest(innerFetch, { input, init, request }, apiOrigin, allowed, cache); await sniffAndPopulateCache(response, cache); return response; }; @@ -132,29 +133,37 @@ function populateCache(value: unknown, cache: BrowserRouteCache): void { async function routeRequest( innerFetch: Fetch, - request: Request, + { + 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(request); + return innerFetch(input, init); } const match = url.pathname.match(/^\/(?:v\d+\/)?browsers\/([^/]+)\/([^/]+)(\/.*)?$/); if (!match) { - return innerFetch(request); + return innerFetch(input, init); } const sessionId = decodeURIComponent(match[1] ?? ''); const subresource = match[2] ?? ''; if (!sessionId || !allowed.has(subresource)) { - return innerFetch(request); + return innerFetch(input, init); } const route = cache.get(sessionId); if (route === undefined) { - return innerFetch(request); + return innerFetch(input, init); } const target = new URL(joinURL(route.baseURL, `/${subresource}${match[3] ?? ''}`)); @@ -169,23 +178,57 @@ async function routeRequest( 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 init: RequestInit = { + const routedInit = { + ...((originalInit ?? {}) as Record), method, headers, redirect: request.redirect, signal: request.signal, - }; - if (method !== 'GET' && method !== 'HEAD' && request.body) { - init.body = request.body; - init.duplex = 'half'; + } 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 innerFetch(new Request(target.toString(), init)); + return request.body ?? undefined; } -function joinURL(baseURL: string, path: string): string { - return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; +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 { diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index 5ecba622..20937d75 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -127,6 +127,36 @@ describe('browser routing', () => { }); }); + 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({ @@ -173,6 +203,31 @@ describe('browser routing', () => { await expect(kernel.browsers.fetch('sess-1', 'https://example.com/again')).rejects.toThrow(/route cache/); }); + test('browser.fetch supports HEAD requests', async () => { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input, init?: RequestInit) => { + const url = normalizeURL(input); + calls.push({ url, init }); + return new Response(null, { status: 204 }); + }, + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + const response = await kernel.browsers.fetch('sess-1', 'https://example.com/hello', { method: 'HEAD' }); + + expect(response.status).toBe(204); + expect(calls[0]?.url).toContain('http://browser-session.test/browser/kernel/curl/raw?'); + expect(calls[0]?.init?.method).toBe('HEAD'); + }); + test('defaults browser routing subresources to curl when env is unset', async () => { await withBrowserRoutingEnv(undefined, async () => { expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl']); From 0e0e88fd88d3f1c11a9b3dc5f10c3c379a10bf96 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 15:18:50 -0400 Subject: [PATCH 15/19] fix: limit browser route cache sniffing Only parse cloned JSON responses for browser metadata endpoints so unrelated API calls don't pay the route cache warm-up cost, and cover the regression with a focused routing fetch test. Made-with: Cursor --- src/lib/browser-routing.ts | 11 ++++++++++- tests/lib/browser-routing.test.ts | 32 ++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index e1d3f172..4e153e03 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -29,6 +29,7 @@ export class BrowserRouteCache { const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl']; +const BROWSER_ROUTE_CACHEABLE_PATH = /^\/(?:v\d+\/)?browsers(?:\/[^/]+)?\/?$/; export function browserRoutingSubresourcesFromEnv(): string[] { const raw = readBrowserRoutingSubresourcesEnv(); @@ -63,12 +64,20 @@ export function createRoutingFetch( 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); - await sniffAndPopulateCache(response, cache); + if (shouldSniff) { + await sniffAndPopulateCache(response, 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); +} + function browserRouteFromValue(value: unknown): BrowserRoute | undefined { if (!value || typeof value !== 'object') { return undefined; diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index 20937d75..a7bd2f17 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -1,6 +1,10 @@ import Kernel from '@onkernel/sdk'; -import { browserRoutingSubresourcesFromEnv } from '../../src/lib/browser-routing'; +import { + BrowserRouteCache, + browserRoutingSubresourcesFromEnv, + createRoutingFetch, +} from '../../src/lib/browser-routing'; describe('browser routing', () => { const browserRoutingEnv = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; @@ -127,6 +131,32 @@ describe('browser routing', () => { }); }); + 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'); From 2d0056e0dabe2e4716d50d053a12e2b8e95048a0 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 16:04:50 -0400 Subject: [PATCH 16/19] fix: evict deleted browser routes Drop cached browser routing entries after successful DELETE /browsers/:id responses so stale base URLs are not reused, and cover both the success and failure paths in the routing tests. Made-with: Cursor --- src/lib/browser-routing.ts | 30 ++++++++++++++++++++ tests/lib/browser-routing.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index 4e153e03..25c0876f 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -30,6 +30,7 @@ export class BrowserRouteCache { 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_DELETE_BY_ID_PATH = /^\/(?:v\d+\/)?browsers\/([^/]+)\/?$/; export function browserRoutingSubresourcesFromEnv(): string[] { const raw = readBrowserRoutingSubresourcesEnv(); @@ -69,6 +70,7 @@ export function createRoutingFetch( if (shouldSniff) { await sniffAndPopulateCache(response, cache); } + maybeEvictDeletedBrowserRoute(request, response, apiOrigin, cache); return response; }; } @@ -78,6 +80,34 @@ function shouldSniffAndPopulateCache(request: Request, apiOrigin: string): boole return url.origin === apiOrigin && BROWSER_ROUTE_CACHEABLE_PATH.test(url.pathname); } +function maybeEvictDeletedBrowserRoute( + request: Request, + response: Response, + apiOrigin: string, + cache: BrowserRouteCache, +): void { + if (!response.ok || request.method.toUpperCase() !== 'DELETE') { + return; + } + + const url = new URL(request.url); + if (url.origin !== apiOrigin) { + return; + } + + const match = url.pathname.match(BROWSER_DELETE_BY_ID_PATH); + if (!match) { + return; + } + + const sessionId = decodeURIComponent(match[1] ?? ''); + if (!sessionId) { + return; + } + + cache.delete(sessionId); +} + function browserRouteFromValue(value: unknown): BrowserRoute | undefined { if (!value || typeof value !== 'object') { return undefined; diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index a7bd2f17..6ed000f3 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -233,6 +233,52 @@ describe('browser routing', () => { await expect(kernel.browsers.fetch('sess-1', 'https://example.com/again')).rejects.toThrow(/route cache/); }); + 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('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('browser.fetch supports HEAD requests', async () => { const calls: Array<{ url: string; init: RequestInit | undefined }> = []; const kernel = new Kernel({ From a76f7ae7e8d040835cd38eb026fad626dfa718db Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 16:36:55 -0400 Subject: [PATCH 17/19] fix: keep browser routing helpers out of generated code Restore the generated internal types file so browser routing changes stay in custom code only. Move joinURL into lib and keep browser.fetch limited to the SDK's existing HTTPMethod union. Made-with: Cursor --- src/internal/types.ts | 6 +----- src/lib/browser-fetch.ts | 4 ++-- src/lib/browser-routing.ts | 2 +- .../utils/url.ts => lib/join-url.ts} | 0 tests/lib/browser-routing.test.ts | 20 ++++++++----------- 5 files changed, 12 insertions(+), 20 deletions(-) rename src/{internal/utils/url.ts => lib/join-url.ts} (100%) diff --git a/src/internal/types.ts b/src/internal/types.ts index e6407bc0..e864cdde 100644 --- a/src/internal/types.ts +++ b/src/internal/types.ts @@ -1,7 +1,7 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. export type PromiseOrValue = T | Promise; -export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options'; +export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; export type KeysEnum = { [P in keyof Required]: true }; @@ -64,18 +64,14 @@ type OverloadedParameters = * [1]: https://www.typescriptlang.org/tsconfig/#typeAcquisition */ /** @ts-ignore For users with \@types/node */ -// prettier-ignore type UndiciTypesRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users with undici */ -// prettier-ignore type UndiciRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users with \@types/bun */ type BunRequestInit = globalThis.FetchRequestInit; /** @ts-ignore For users with node-fetch@2 */ -// prettier-ignore type NodeFetch2RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users with node-fetch@3, doesn't need file extension because types are at ./@types/index.d.ts */ -// prettier-ignore type NodeFetch3RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users who use Deno */ type FetchRequestInit = NonNullable[1]>; diff --git a/src/lib/browser-fetch.ts b/src/lib/browser-fetch.ts index 6b6b2632..63cbc18e 100644 --- a/src/lib/browser-fetch.ts +++ b/src/lib/browser-fetch.ts @@ -3,7 +3,7 @@ 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 '../internal/utils/url'; +import { joinURL } from './join-url'; import type { Kernel } from '../client'; export interface BrowserFetchInit extends RequestInit { @@ -54,7 +54,7 @@ export async function browserFetch( function normalizeMethod(method: string): HTTPMethod { const methodLower = method.toLowerCase(); - const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); + const allowed = new Set(['get', 'post', 'put', 'patch', 'delete']); if (!allowed.has(methodLower)) { throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`); } diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index 25c0876f..251a3499 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -1,5 +1,5 @@ import type { Fetch, RequestInfo, RequestInit } from '../internal/builtin-types'; -import { joinURL } from '../internal/utils/url'; +import { joinURL } from './join-url'; export type BrowserRoute = { sessionId: string; diff --git a/src/internal/utils/url.ts b/src/lib/join-url.ts similarity index 100% rename from src/internal/utils/url.ts rename to src/lib/join-url.ts diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index 6ed000f3..af801657 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -279,16 +279,11 @@ describe('browser routing', () => { }); }); - test('browser.fetch supports HEAD requests', async () => { - const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + test('browser.fetch rejects methods outside the SDK HTTPMethod union', async () => { const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/', - fetch: async (input, init?: RequestInit) => { - const url = normalizeURL(input); - calls.push({ url, init }); - return new Response(null, { status: 204 }); - }, + fetch: async () => new Response(null, { status: 204 }), }); kernel.browserRouteCache.set({ @@ -297,11 +292,12 @@ describe('browser routing', () => { jwt: 'token-abc', }); - const response = await kernel.browsers.fetch('sess-1', 'https://example.com/hello', { method: 'HEAD' }); - - expect(response.status).toBe(204); - expect(calls[0]?.url).toContain('http://browser-session.test/browser/kernel/curl/raw?'); - expect(calls[0]?.init?.method).toBe('HEAD'); + 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 () => { From a7ff9bcae419cefbebaccaf4e07545504ec39356 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 16:41:19 -0400 Subject: [PATCH 18/19] fix: restore generated types formatting Restore src/internal/types.ts byte-for-byte to the generated base version. This drops the file from the PR so browser routing changes stay out of generated code. Made-with: Cursor --- src/internal/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/types.ts b/src/internal/types.ts index e864cdde..b668dfc0 100644 --- a/src/internal/types.ts +++ b/src/internal/types.ts @@ -72,7 +72,7 @@ type BunRequestInit = globalThis.FetchRequestInit; /** @ts-ignore For users with node-fetch@2 */ type NodeFetch2RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users with node-fetch@3, doesn't need file extension because types are at ./@types/index.d.ts */ -type NodeFetch3RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +type NodeFetch3RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; /** @ts-ignore For users who use Deno */ type FetchRequestInit = NonNullable[1]>; /* eslint-enable */ From 81e47caffea08415750b6a3480b59a4f698c4187 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 17:07:28 -0400 Subject: [PATCH 19/19] fix: handle browser pool route cache updates Warm the browser route cache from browser pool acquire responses and evict released sessions after successful pool releases. Keep this behavior in the routing middleware so generated resource methods stay untouched. Made-with: Cursor --- src/lib/browser-routing.ts | 52 ++++++++++++++++---- tests/lib/browser-routing.test.ts | 81 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 10 deletions(-) diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index 251a3499..6580da6c 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -30,7 +30,9 @@ export class BrowserRouteCache { 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(); @@ -70,23 +72,26 @@ export function createRoutingFetch( if (shouldSniff) { await sniffAndPopulateCache(response, cache); } - maybeEvictDeletedBrowserRoute(request, response, apiOrigin, 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); + return ( + url.origin === apiOrigin && + (BROWSER_ROUTE_CACHEABLE_PATH.test(url.pathname) || BROWSER_POOL_ACQUIRE_PATH.test(url.pathname)) + ); } -function maybeEvictDeletedBrowserRoute( +async function maybeEvictBrowserRoute( request: Request, response: Response, apiOrigin: string, cache: BrowserRouteCache, -): void { - if (!response.ok || request.method.toUpperCase() !== 'DELETE') { +): Promise { + if (!response.ok) { return; } @@ -95,17 +100,44 @@ function maybeEvictDeletedBrowserRoute( return; } - const match = url.pathname.match(BROWSER_DELETE_BY_ID_PATH); + 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; + return undefined; } const sessionId = decodeURIComponent(match[1] ?? ''); - if (!sessionId) { - return; + return sessionId || undefined; +} + +async function releasedSessionIdFromRequest(request: Request, pathname: string): Promise { + if (request.method.toUpperCase() !== 'POST' || !BROWSER_POOL_RELEASE_PATH.test(pathname)) { + return undefined; } - cache.delete(sessionId); + 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 { diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index af801657..1ca285cc 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -233,6 +233,41 @@ describe('browser routing', () => { 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({ @@ -257,6 +292,30 @@ describe('browser routing', () => { 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', @@ -279,6 +338,28 @@ describe('browser routing', () => { }); }); + 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',