Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
13 changes: 13 additions & 0 deletions examples/browser-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Kernel from '@onkernel/sdk';

async function main() {
const kernel = new Kernel();

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

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

void main();
23 changes: 21 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import * as Uploads from './core/uploads';
import * as API from './resources/index';
import { APIPromise } from './core/api-promise';
import { AppListParams, AppListResponse, AppListResponsesOffsetPagination, Apps } from './resources/apps';
import {
BrowserRouteCache,
browserRoutingSubresourcesFromEnv,
createRoutingFetch,
} from './lib/browser-routing';
import {
BrowserPool,
BrowserPoolAcquireParams,
Expand Down Expand Up @@ -247,9 +252,11 @@ export class Kernel {
fetchOptions: MergedRequestInit | undefined;

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

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

this._options = options;
Expand All @@ -332,11 +345,17 @@ export class Kernel {
timeout: this.timeout,
logger: this.logger,
logLevel: this.logLevel,
fetch: this.fetch,
fetch: this.rawFetch,
fetchOptions: this.fetchOptions,
apiKey: this.apiKey,
...options,
});
client.browserRouteCache = this.browserRouteCache;
client.fetch = createRoutingFetch(client.rawFetch, {
apiBaseURL: client.baseURL,
subresources: browserRoutingSubresourcesFromEnv(),
cache: client.browserRouteCache,
});
return client;
}

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 } from './lib/browser-routing';
export { PagePromise } from './core/pagination';
export {
KernelError,
Expand Down
180 changes: 180 additions & 0 deletions src/lib/browser-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { RequestInfo, RequestInit } from '../internal/builtin-types';
import { KernelError } from '../core/error';
import { buildHeaders } from '../internal/headers';
import type { FinalRequestOptions, RequestOptions } from '../internal/request-options';
import type { HTTPMethod } from '../internal/types';
import { joinURL } from './join-url';
import type { Kernel } from '../client';

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

export async function browserFetch(
client: Kernel,
sessionId: string,
input: RequestInfo | URL,
init?: BrowserFetchInit,
): Promise<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']);
if (!allowed.has(methodLower)) {
throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`);
}
return methodLower as HTTPMethod;
}
Comment thread
cursor[bot] marked this conversation as resolved.

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;
}
Loading
Loading