Skip to content

Commit e36b009

Browse files
authored
Merge pull request #99 from kernel/raf/browser-scoped-client
feat: add browser routing cache
2 parents 410b647 + 81e47ca commit e36b009

8 files changed

Lines changed: 953 additions & 2 deletions

File tree

examples/browser-routing.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Kernel from '@onkernel/sdk';
2+
3+
async function main() {
4+
const kernel = new Kernel();
5+
6+
const browser = await kernel.browsers.create({});
7+
const response = await kernel.browsers.fetch(browser.session_id, 'https://example.com', { method: 'GET' });
8+
console.log('status', response.status);
9+
10+
await kernel.browsers.deleteByID(browser.session_id);
11+
}
12+
13+
void main();

src/client.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import * as Uploads from './core/uploads';
2020
import * as API from './resources/index';
2121
import { APIPromise } from './core/api-promise';
2222
import { AppListParams, AppListResponse, AppListResponsesOffsetPagination, Apps } from './resources/apps';
23+
import {
24+
BrowserRouteCache,
25+
browserRoutingSubresourcesFromEnv,
26+
createRoutingFetch,
27+
} from './lib/browser-routing';
2328
import {
2429
BrowserPool,
2530
BrowserPoolAcquireParams,
@@ -247,9 +252,11 @@ export class Kernel {
247252
fetchOptions: MergedRequestInit | undefined;
248253

249254
private fetch: Fetch;
255+
private rawFetch: Fetch;
250256
#encoder: Opts.RequestEncoder;
251257
protected idempotencyHeader?: string;
252258
private _options: ClientOptions;
259+
public browserRouteCache: BrowserRouteCache;
253260

254261
/**
255262
* API Client for interfacing with the Kernel API.
@@ -312,7 +319,13 @@ export class Kernel {
312319
defaultLogLevel;
313320
this.fetchOptions = options.fetchOptions;
314321
this.maxRetries = options.maxRetries ?? 2;
315-
this.fetch = options.fetch ?? Shims.getDefaultFetch();
322+
this.rawFetch = options.fetch ?? Shims.getDefaultFetch();
323+
this.browserRouteCache = new BrowserRouteCache();
324+
this.fetch = createRoutingFetch(this.rawFetch, {
325+
apiBaseURL: this.baseURL,
326+
subresources: browserRoutingSubresourcesFromEnv(),
327+
cache: this.browserRouteCache,
328+
});
316329
this.#encoder = Opts.FallbackEncoder;
317330

318331
this._options = options;
@@ -332,11 +345,17 @@ export class Kernel {
332345
timeout: this.timeout,
333346
logger: this.logger,
334347
logLevel: this.logLevel,
335-
fetch: this.fetch,
348+
fetch: this.rawFetch,
336349
fetchOptions: this.fetchOptions,
337350
apiKey: this.apiKey,
338351
...options,
339352
});
353+
client.browserRouteCache = this.browserRouteCache;
354+
client.fetch = createRoutingFetch(client.rawFetch, {
355+
apiBaseURL: client.baseURL,
356+
subresources: browserRoutingSubresourcesFromEnv(),
357+
cache: client.browserRouteCache,
358+
});
340359
return client;
341360
}
342361

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export { Kernel as default } from './client';
55
export { type Uploadable, toFile } from './core/uploads';
66
export { APIPromise } from './core/api-promise';
77
export { Kernel, type ClientOptions } from './client';
8+
export { type BrowserFetchInit } from './lib/browser-fetch';
9+
export { BrowserRouteCache, type BrowserRoute } from './lib/browser-routing';
810
export { PagePromise } from './core/pagination';
911
export {
1012
KernelError,

src/lib/browser-fetch.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import type { RequestInfo, RequestInit } from '../internal/builtin-types';
2+
import { KernelError } from '../core/error';
3+
import { buildHeaders } from '../internal/headers';
4+
import type { FinalRequestOptions, RequestOptions } from '../internal/request-options';
5+
import type { HTTPMethod } from '../internal/types';
6+
import { joinURL } from './join-url';
7+
import type { Kernel } from '../client';
8+
9+
export interface BrowserFetchInit extends RequestInit {
10+
timeout_ms?: number;
11+
}
12+
13+
export async function browserFetch(
14+
client: Kernel,
15+
sessionId: string,
16+
input: RequestInfo | URL,
17+
init?: BrowserFetchInit,
18+
): Promise<Response> {
19+
const route = client.browserRouteCache.get(sessionId);
20+
if (!route) {
21+
throw new KernelError(
22+
`browser route cache does not contain session ${sessionId}; create, retrieve, or list the browser before calling browser.fetch`,
23+
);
24+
}
25+
26+
const { url: targetURL, method, headers, body, signal, duplex, timeout_ms } = splitFetchArgs(input, init);
27+
assertHTTPURL(targetURL);
28+
29+
const query: Record<string, unknown> = { url: targetURL, jwt: route.jwt };
30+
if (timeout_ms !== undefined) {
31+
query['timeout_ms'] = timeout_ms;
32+
}
33+
34+
const accept = headers.get('accept');
35+
const requestOptions: FinalRequestOptions = {
36+
method: normalizeMethod(method),
37+
path: joinURL(route.baseURL, '/curl/raw'),
38+
query,
39+
body: body as RequestOptions['body'],
40+
headers: buildHeaders([
41+
{ Authorization: null },
42+
accept ? { Accept: accept } : { Accept: '*/*' },
43+
headersToRequestOptionsHeaders(headers),
44+
]),
45+
signal: signal ?? null,
46+
__binaryResponse: true,
47+
};
48+
if (duplex) {
49+
requestOptions.fetchOptions = { duplex } as NonNullable<RequestOptions['fetchOptions']>;
50+
}
51+
52+
return client.request(requestOptions).asResponse();
53+
}
54+
55+
function normalizeMethod(method: string): HTTPMethod {
56+
const methodLower = method.toLowerCase();
57+
const allowed = new Set(['get', 'post', 'put', 'patch', 'delete']);
58+
if (!allowed.has(methodLower)) {
59+
throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`);
60+
}
61+
return methodLower as HTTPMethod;
62+
}
63+
64+
function splitFetchArgs(
65+
input: RequestInfo | URL,
66+
init?: BrowserFetchInit,
67+
): {
68+
url: string;
69+
method: string;
70+
headers: Headers;
71+
body?: RequestInit['body'];
72+
signal?: AbortSignal | null;
73+
duplex?: RequestInit['duplex'];
74+
timeout_ms?: number;
75+
} {
76+
const timeoutFromInit = init && 'timeout_ms' in init ? init['timeout_ms'] : undefined;
77+
78+
if (input instanceof Request) {
79+
const headers = new Headers(input.headers);
80+
if (init?.headers) {
81+
const extra = new Headers(init.headers);
82+
extra.forEach((value, key) => {
83+
headers.set(key, value);
84+
});
85+
}
86+
87+
const out: {
88+
url: string;
89+
method: string;
90+
headers: Headers;
91+
body?: RequestInit['body'];
92+
signal?: AbortSignal | null;
93+
duplex?: RequestInit['duplex'];
94+
timeout_ms?: number;
95+
} = {
96+
url: input.url,
97+
method: (init?.method ?? input.method)?.toUpperCase() || 'GET',
98+
headers,
99+
};
100+
const body = init?.body ?? input.body;
101+
if (body !== undefined && body !== null) {
102+
out.body = body;
103+
}
104+
const signal = init?.signal ?? input.signal;
105+
if (signal !== undefined) {
106+
out.signal = signal;
107+
}
108+
if (init?.duplex !== undefined) {
109+
out.duplex = init.duplex;
110+
}
111+
if (timeoutFromInit !== undefined) {
112+
out.timeout_ms = timeoutFromInit;
113+
}
114+
return out;
115+
}
116+
117+
const out: {
118+
url: string;
119+
method: string;
120+
headers: Headers;
121+
body?: RequestInit['body'];
122+
signal?: AbortSignal | null;
123+
duplex?: RequestInit['duplex'];
124+
timeout_ms?: number;
125+
} = {
126+
url: input instanceof URL ? input.href : String(input),
127+
method: (init?.method ?? 'GET').toUpperCase(),
128+
headers: new Headers(init?.headers),
129+
};
130+
if (init?.body !== undefined) {
131+
out.body = init.body;
132+
}
133+
if (init?.signal !== undefined) {
134+
out.signal = init.signal;
135+
}
136+
if (init?.duplex !== undefined) {
137+
out.duplex = init.duplex;
138+
}
139+
if (timeoutFromInit !== undefined) {
140+
out.timeout_ms = timeoutFromInit;
141+
}
142+
return out;
143+
}
144+
145+
function assertHTTPURL(url: string): void {
146+
let parsed: URL;
147+
try {
148+
parsed = new URL(url);
149+
} catch {
150+
throw new KernelError(`browser.fetch target must be an absolute URL; received: ${url}`);
151+
}
152+
153+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
154+
throw new KernelError(`browser.fetch only supports http(s) URLs; received: ${parsed.protocol}`);
155+
}
156+
}
157+
158+
function headersToRequestOptionsHeaders(headers: Headers): Record<string, string | null | undefined> {
159+
const out: Record<string, string | null | undefined> = {};
160+
161+
headers.forEach((value, key) => {
162+
switch (key.toLowerCase()) {
163+
case 'accept':
164+
case 'content-length':
165+
case 'connection':
166+
case 'keep-alive':
167+
case 'proxy-authenticate':
168+
case 'proxy-authorization':
169+
case 'te':
170+
case 'trailers':
171+
case 'transfer-encoding':
172+
case 'upgrade':
173+
return;
174+
default:
175+
out[key] = value;
176+
}
177+
});
178+
179+
return out;
180+
}

0 commit comments

Comments
 (0)