From fc7c6f91802f7ec8a1b5e99dbd4fe10cf1e75569 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 11:51:38 -0400 Subject: [PATCH 1/4] 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 0000000..1ccf5c8 --- /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 dc9e5ce..e36f852 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, @@ -879,6 +880,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 72d9bc0..eaafa63 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 0000000..defac41 --- /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 0000000..7a8e45a --- /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 0000000..e81bb23 --- /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 0000000..9fe5abb --- /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 7486cd4888ba5d300e35d464f39ac8791aab5457 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:09:22 -0400 Subject: [PATCH 2/4] 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 1ccf5c8..33f4c5f 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 e36f852..8be4b37 100644 --- a/src/client.ts +++ b/src/client.ts @@ -881,9 +881,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 defac41..d355038 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 7a8e45a..e35fdae 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 f587ded..76315b2 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 c4a910e..9fbcf3e 100644 --- a/src/resources/browsers/browsers.ts +++ b/src/resources/browsers/browsers.ts @@ -319,7 +319,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; @@ -425,7 +425,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; @@ -531,7 +531,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; @@ -637,7 +637,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 430751b..011637c 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 e81bb23..2eba521 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 9fe5abb..5291ab0 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 a9057ac676b4b88b712ae344faec5da31bdf7cfb Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:19:45 -0400 Subject: [PATCH 3/4] 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 e35fdae..45f3320 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 c5731cb9e5b2ebd876088289c3931f92fc90a08e Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 18:27:02 -0400 Subject: [PATCH 4/4] 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 08d4984..8437534 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 a008cb0..8d747f2 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 0000000..4369163 --- /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 3ffb78a..caaa979 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 0000000..4190c10 --- /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 45f3320..21f47a8 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 f6eae3c..8480389 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"