Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions examples/browser-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Kernel from '@onkernel/sdk';

async function main() {
const kernel = new Kernel({
browserRouting: {
enabled: true,
subresources: ['computer'],
},
});

const browser = await kernel.browsers.create({});
await kernel.browsers.computer.clickMouse(browser.session_id, { x: 10, y: 10 });
const response = await kernel.browsers.fetch(browser.session_id, 'https://example.com', { method: 'GET' });
console.log('status', response.status);

await kernel.browsers.deleteByID(browser.session_id);
}

void main();
32 changes: 30 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import * as Uploads from './core/uploads';
import * as API from './resources/index';
import { APIPromise } from './core/api-promise';
import { AppListParams, AppListResponse, AppListResponsesOffsetPagination, Apps } from './resources/apps';
import { BrowserRouteCache, createRoutingFetch, type BrowserRoutingOptions } from './lib/browser-routing';
import {
BrowserPool,
BrowserPoolAcquireParams,
Expand Down Expand Up @@ -194,6 +195,11 @@ export interface ClientOptions {
*/
fetch?: Fetch | undefined;

/**
* Configure direct-to-VM routing for browser subresource requests.
*/
browserRouting?: BrowserRoutingOptions | undefined;

/**
* The maximum number of times that the client will retry a request in case of a
* temporary failure, like a network error or a 5XX error from the server.
Expand Down Expand Up @@ -247,9 +253,11 @@ export class Kernel {
fetchOptions: MergedRequestInit | undefined;

private fetch: Fetch;
private rawFetch: Fetch;
#encoder: Opts.RequestEncoder;
protected idempotencyHeader?: string;
private _options: ClientOptions;
public browserRouteCache: BrowserRouteCache;

/**
* API Client for interfacing with the Kernel API.
Expand Down Expand Up @@ -312,7 +320,16 @@ export class Kernel {
defaultLogLevel;
this.fetchOptions = options.fetchOptions;
this.maxRetries = options.maxRetries ?? 2;
this.fetch = options.fetch ?? Shims.getDefaultFetch();
this.rawFetch = options.fetch ?? Shims.getDefaultFetch();
this.browserRouteCache = options.browserRouting?.cache ?? new BrowserRouteCache();
this.fetch =
options.browserRouting?.enabled ?
createRoutingFetch(this.rawFetch, {
apiBaseURL: this.baseURL,
subresources: options.browserRouting.subresources ?? [],
cache: this.browserRouteCache,
})
: this.rawFetch;
this.#encoder = Opts.FallbackEncoder;

this._options = options;
Expand All @@ -324,6 +341,16 @@ export class Kernel {
* Create a new client instance re-using the same options given to the current client with optional overriding.
*/
withOptions(options: Partial<ClientOptions>): this {
const currentRouting = this._options.browserRouting;
const nextBrowserRouting = options.browserRouting === undefined ? currentRouting : options.browserRouting;
const sharedBrowserRouting =
nextBrowserRouting ?
{
...nextBrowserRouting,
cache: nextBrowserRouting.cache ?? this.browserRouteCache,
}
: undefined;

const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({
...this._options,
environment: options.environment ? options.environment : undefined,
Expand All @@ -332,10 +359,11 @@ export class Kernel {
timeout: this.timeout,
logger: this.logger,
logLevel: this.logLevel,
fetch: this.fetch,
fetch: this.rawFetch,
fetchOptions: this.fetchOptions,
apiKey: this.apiKey,
...options,
browserRouting: sharedBrowserRouting,
});
return client;
}
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export { Kernel as default } from './client';
export { type Uploadable, toFile } from './core/uploads';
export { APIPromise } from './core/api-promise';
export { Kernel, type ClientOptions } from './client';
export { type BrowserFetchInit } from './lib/browser-fetch';
export { BrowserRouteCache, type BrowserRoute, type BrowserRoutingOptions } from './lib/browser-routing';
export { PagePromise } from './core/pagination';
export {
KernelError,
Expand Down
183 changes: 183 additions & 0 deletions src/lib/browser-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import type { RequestInfo, RequestInit } from '../internal/builtin-types';
import { KernelError } from '../core/error';
import { buildHeaders } from '../internal/headers';
import type { FinalRequestOptions, RequestOptions } from '../internal/request-options';
import type { HTTPMethod } from '../internal/types';
import type { Kernel } from '../client';

export interface BrowserFetchInit extends RequestInit {
timeout_ms?: number;
}

export async function browserFetch(
client: Kernel,
sessionId: string,
input: RequestInfo | URL,
init?: BrowserFetchInit,
): Promise<Response> {
const route = client.browserRouteCache.get(sessionId);
if (!route) {
throw new KernelError(
`browser route cache does not contain session ${sessionId}; create, retrieve, or list the browser before calling browser.fetch`,
);
}

const { url: targetURL, method, headers, body, signal, duplex, timeout_ms } = splitFetchArgs(input, init);
assertHTTPURL(targetURL);

const query: Record<string, unknown> = { url: targetURL, jwt: route.jwt };
if (timeout_ms !== undefined) {
query['timeout_ms'] = timeout_ms;
}

const accept = headers.get('accept');
const requestOptions: FinalRequestOptions = {
method: normalizeMethod(method),
path: joinURL(route.baseURL, '/curl/raw'),
query,
body: body as RequestOptions['body'],
headers: buildHeaders([
{ Authorization: null },
accept ? { Accept: accept } : { Accept: '*/*' },
headersToRequestOptionsHeaders(headers),
]),
signal: signal ?? null,
__binaryResponse: true,
};
if (duplex) {
requestOptions.fetchOptions = { duplex } as NonNullable<RequestOptions['fetchOptions']>;
}

return client.request(requestOptions).asResponse();
}

function normalizeMethod(method: string): HTTPMethod {
const methodLower = method.toLowerCase();
const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);
if (!allowed.has(methodLower)) {
throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`);
}
return methodLower as HTTPMethod;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeMethod allows methods outside HTTPMethod type union

Low Severity

normalizeMethod validates and passes through 'head' and 'options', then casts the result as HTTPMethod. However, HTTPMethod is defined as 'get' | 'post' | 'put' | 'patch' | 'delete' — it does not include those two methods. The as cast silently lies to the type system, which could mask issues if any downstream code narrows on HTTPMethod variants.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fdd3adf. Configure here.


function splitFetchArgs(
input: RequestInfo | URL,
init?: BrowserFetchInit,
): {
url: string;
method: string;
headers: Headers;
body?: RequestInit['body'];
signal?: AbortSignal | null;
duplex?: RequestInit['duplex'];
timeout_ms?: number;
} {
const timeoutFromInit = init && 'timeout_ms' in init ? init['timeout_ms'] : undefined;

if (input instanceof Request) {
const headers = new Headers(input.headers);
if (init?.headers) {
const extra = new Headers(init.headers);
extra.forEach((value, key) => {
headers.set(key, value);
});
}

const out: {
url: string;
method: string;
headers: Headers;
body?: RequestInit['body'];
signal?: AbortSignal | null;
duplex?: RequestInit['duplex'];
timeout_ms?: number;
} = {
url: input.url,
method: (init?.method ?? input.method)?.toUpperCase() || 'GET',
headers,
};
const body = init?.body ?? input.body;
if (body !== undefined && body !== null) {
out.body = body;
}
const signal = init?.signal ?? input.signal;
if (signal !== undefined) {
out.signal = signal;
}
if (init?.duplex !== undefined) {
out.duplex = init.duplex;
}
if (timeoutFromInit !== undefined) {
out.timeout_ms = timeoutFromInit;
}
return out;
}

const out: {
url: string;
method: string;
headers: Headers;
body?: RequestInit['body'];
signal?: AbortSignal | null;
duplex?: RequestInit['duplex'];
timeout_ms?: number;
} = {
url: input instanceof URL ? input.href : String(input),
method: (init?.method ?? 'GET').toUpperCase(),
headers: new Headers(init?.headers),
};
if (init?.body !== undefined) {
out.body = init.body;
}
if (init?.signal !== undefined) {
out.signal = init.signal;
}
if (init?.duplex !== undefined) {
out.duplex = init.duplex;
}
if (timeoutFromInit !== undefined) {
out.timeout_ms = timeoutFromInit;
}
return out;
}

function assertHTTPURL(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new KernelError(`browser.fetch target must be an absolute URL; received: ${url}`);
}

if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new KernelError(`browser.fetch only supports http(s) URLs; received: ${parsed.protocol}`);
}
}

function headersToRequestOptionsHeaders(headers: Headers): Record<string, string | null | undefined> {
const out: Record<string, string | null | undefined> = {};

headers.forEach((value, key) => {
switch (key.toLowerCase()) {
case 'accept':
case 'content-length':
case 'connection':
case 'keep-alive':
case 'proxy-authenticate':
case 'proxy-authorization':
case 'te':
case 'trailers':
case 'transfer-encoding':
case 'upgrade':
return;
default:
out[key] = value;
}
});

return out;
}

function joinURL(baseURL: string, path: string): string {
return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate joinURL helper in two new files

Low Severity

Both browser-fetch.ts and browser-routing.ts define identical private joinURL functions with the same signature and implementation. This duplicated logic increases maintenance burden — a fix in one copy could easily be missed in the other. One shared utility would be cleaner.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fdd3adf. Configure here.

Loading
Loading