Skip to content

Commit b09434e

Browse files
committed
feat: restore node browser fetch helper
Bring back the cache-backed browser fetch helper so raw HTTP stays on the SDK's language-native surface instead of falling through to manual /curl/raw requests. Made-with: Cursor
1 parent 0d9ddce commit b09434e

5 files changed

Lines changed: 215 additions & 8 deletions

File tree

examples/browser-routing.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,13 @@ async function main() {
44
const kernel = new Kernel({
55
browserRouting: {
66
enabled: true,
7-
subresources: ['computer', 'curl'],
7+
subresources: ['computer'],
88
},
99
});
1010

1111
const browser = await kernel.browsers.create({});
1212
await kernel.browsers.computer.clickMouse(browser.session_id, { x: 10, y: 10 });
13-
14-
const response = await kernel
15-
.get(`/browsers/${browser.session_id}/curl/raw`, {
16-
query: { url: 'https://example.com' },
17-
})
18-
.asResponse();
13+
const response = await kernel.browsers.fetch(browser.session_id, 'https://example.com', { method: 'GET' });
1914
console.log('status', response.status);
2015

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

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { APIPromise } from './core/api-promise';
77
export { Kernel, type ClientOptions } from './client';
88
export {
99
BrowserRouteCache,
10+
type BrowserFetchInit,
1011
type BrowserRoute,
1112
type BrowserRoutingOptions,
1213
} from './lib/browser-routing';

src/lib/browser-routing.ts

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import type { Fetch } from '../internal/builtin-types';
1+
import type { RequestInfo, RequestInit, Fetch } 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';
26
import { parseJwtFromCdpWsUrl } from './browser-transport';
7+
import type { Kernel } from '../client';
38

49
export type BrowserRoute = {
510
sessionId: string;
@@ -13,6 +18,10 @@ export interface BrowserRoutingOptions {
1318
cache?: BrowserRouteCache | undefined;
1419
}
1520

21+
export interface BrowserFetchInit extends RequestInit {
22+
timeout_ms?: number;
23+
}
24+
1625
export class BrowserRouteCache {
1726
private entries = new Map<string, BrowserRoute>();
1827

@@ -60,6 +69,51 @@ export function createRoutingFetch(
6069
};
6170
}
6271

72+
export async function browserFetch(
73+
client: Kernel,
74+
sessionId: string,
75+
input: RequestInfo | URL,
76+
init?: BrowserFetchInit,
77+
): Promise<Response> {
78+
const route = client.browserRouteCache.get(sessionId);
79+
if (!route) {
80+
throw new KernelError(
81+
`browser route cache does not contain session ${sessionId}; create, retrieve, or list the browser before calling browser.fetch`,
82+
);
83+
}
84+
85+
const { url: targetURL, method, headers, body, signal, duplex, timeout_ms } = splitFetchArgs(input, init);
86+
assertHTTPURL(targetURL);
87+
88+
const query: Record<string, unknown> = {
89+
url: targetURL,
90+
jwt: route.jwt,
91+
};
92+
if (timeout_ms !== undefined) {
93+
query['timeout_ms'] = timeout_ms;
94+
}
95+
96+
const accept = headers.get('accept');
97+
const requestOptions: FinalRequestOptions = {
98+
method: normalizeMethod(method),
99+
path: joinURL(route.baseURL, '/curl/raw'),
100+
query,
101+
body: body as RequestOptions['body'],
102+
headers: buildHeaders([
103+
{ Authorization: null },
104+
accept ? { Accept: accept } : { Accept: '*/*' },
105+
headersToRequestOptionsHeaders(headers),
106+
]),
107+
signal: signal ?? null,
108+
__binaryResponse: true,
109+
};
110+
if (duplex) {
111+
requestOptions.fetchOptions = { duplex } as NonNullable<RequestOptions['fetchOptions']>;
112+
}
113+
114+
return client.request(requestOptions).asResponse();
115+
}
116+
63117
function browserRouteFromValue(value: unknown): BrowserRoute | undefined {
64118
if (!value || typeof value !== 'object') {
65119
return undefined;
@@ -180,3 +234,126 @@ function joinURL(baseURL: string, path: string): string {
180234
return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
181235
}
182236

237+
function normalizeMethod(method: string): HTTPMethod {
238+
const methodLower = method.toLowerCase();
239+
const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);
240+
if (!allowed.has(methodLower)) {
241+
throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`);
242+
}
243+
return methodLower as HTTPMethod;
244+
}
245+
246+
function splitFetchArgs(
247+
input: RequestInfo | URL,
248+
init?: BrowserFetchInit,
249+
): {
250+
url: string;
251+
method: string;
252+
headers: Headers;
253+
body?: RequestInit['body'];
254+
signal?: AbortSignal | null;
255+
duplex?: RequestInit['duplex'];
256+
timeout_ms?: number;
257+
} {
258+
const timeoutFromInit = init && 'timeout_ms' in init ? init['timeout_ms'] : undefined;
259+
260+
if (input instanceof Request) {
261+
const merged = new Headers(input.headers);
262+
if (init?.headers) {
263+
const extra = new Headers(init.headers);
264+
extra.forEach((value, key) => {
265+
merged.set(key, value);
266+
});
267+
}
268+
const out: {
269+
url: string;
270+
method: string;
271+
headers: Headers;
272+
body?: RequestInit['body'];
273+
signal?: AbortSignal | null;
274+
duplex?: RequestInit['duplex'];
275+
timeout_ms?: number;
276+
} = {
277+
url: input.url,
278+
method: (init?.method ?? input.method)?.toUpperCase() || 'GET',
279+
headers: merged,
280+
};
281+
const mergedBody = init?.body ?? input.body;
282+
if (mergedBody !== undefined && mergedBody !== null) {
283+
out.body = mergedBody;
284+
}
285+
const mergedSignal = init?.signal ?? input.signal;
286+
if (mergedSignal !== undefined) {
287+
out.signal = mergedSignal;
288+
}
289+
if (init?.duplex !== undefined) {
290+
out.duplex = init.duplex;
291+
}
292+
if (timeoutFromInit !== undefined) {
293+
out.timeout_ms = timeoutFromInit;
294+
}
295+
return out;
296+
}
297+
298+
const url = input instanceof URL ? input.href : String(input);
299+
const method = (init?.method ?? 'GET').toUpperCase();
300+
const headers = new Headers(init?.headers);
301+
const out: {
302+
url: string;
303+
method: string;
304+
headers: Headers;
305+
body?: RequestInit['body'];
306+
signal?: AbortSignal | null;
307+
duplex?: RequestInit['duplex'];
308+
timeout_ms?: number;
309+
} = { url, method, headers };
310+
if (init?.body !== undefined) {
311+
out.body = init.body;
312+
}
313+
if (init?.signal !== undefined) {
314+
out.signal = init.signal;
315+
}
316+
if (init?.duplex !== undefined) {
317+
out.duplex = init.duplex;
318+
}
319+
if (timeoutFromInit !== undefined) {
320+
out.timeout_ms = timeoutFromInit;
321+
}
322+
return out;
323+
}
324+
325+
function assertHTTPURL(url: string): void {
326+
let parsed: URL;
327+
try {
328+
parsed = new URL(url);
329+
} catch {
330+
throw new KernelError(`browser.fetch target must be an absolute URL; received: ${url}`);
331+
}
332+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
333+
throw new KernelError(`browser.fetch only supports http(s) URLs; received: ${parsed.protocol}`);
334+
}
335+
}
336+
337+
function headersToRequestOptionsHeaders(headers: Headers): Record<string, string | null | undefined> {
338+
const out: Record<string, string | null | undefined> = {};
339+
headers.forEach((value, key) => {
340+
const lower = key.toLowerCase();
341+
if (
342+
lower === 'accept' ||
343+
lower === 'content-length' ||
344+
lower === 'connection' ||
345+
lower === 'keep-alive' ||
346+
lower === 'proxy-authenticate' ||
347+
lower === 'proxy-authorization' ||
348+
lower === 'te' ||
349+
lower === 'trailers' ||
350+
lower === 'transfer-encoding' ||
351+
lower === 'upgrade'
352+
) {
353+
return;
354+
}
355+
out[key] = value;
356+
});
357+
return out;
358+
}
359+

src/resources/browsers/browsers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

3+
import type { RequestInfo } from '../../internal/builtin-types';
34
import { APIResource } from '../../core/resource';
45
import * as Shared from '../shared';
56
import * as ComputerAPI from './computer';
@@ -75,6 +76,7 @@ import { buildHeaders } from '../../internal/headers';
7576
import { RequestOptions } from '../../internal/request-options';
7677
import { multipartFormRequestOptions } from '../../internal/uploads';
7778
import { path } from '../../internal/utils/path';
79+
import { browserFetch, type BrowserFetchInit } from '../../lib/browser-routing';
7880

7981
/**
8082
* Create and manage browser sessions.
@@ -184,6 +186,14 @@ export class Browsers extends APIResource {
184186
return this._client.post(path`/browsers/${id}/curl`, { body, ...options });
185187
}
186188

189+
/**
190+
* Issues an HTTP request through the browser VM network stack, routing directly
191+
* to the browser's `base_url` using the shared browser route cache.
192+
*/
193+
fetch(id: string, input: RequestInfo | URL, init?: BrowserFetchInit): Promise<Response> {
194+
return browserFetch(this._client, id, input, init);
195+
}
196+
187197
/**
188198
* Delete a browser session by ID
189199
*

tests/lib/browser-routing.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,28 @@ describe('browser routing', () => {
127127
await kernel.browsers.create();
128128
expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined();
129129
});
130+
131+
test('browser.fetch uses the shared cache and fails clearly on cache miss', async () => {
132+
const calls: string[] = [];
133+
const kernel = new Kernel({
134+
apiKey: 'k',
135+
baseURL: 'https://api.example/',
136+
fetch: async (input) => {
137+
const url = normalizeURL(input);
138+
calls.push(url);
139+
return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } });
140+
},
141+
});
142+
143+
kernel.browserRouteCache.set({
144+
sessionId: 'sess-1',
145+
baseURL: 'http://browser-session.test/browser/kernel',
146+
jwt: 'token-abc',
147+
});
148+
await kernel.browsers.fetch('sess-1', 'https://example.com/hello');
149+
expect(calls[0]).toContain('http://browser-session.test/browser/kernel/curl/raw?');
150+
151+
kernel.browserRouteCache.delete('sess-1');
152+
await expect(kernel.browsers.fetch('sess-1', 'https://example.com/again')).rejects.toThrow(/route cache/);
153+
});
130154
});

0 commit comments

Comments
 (0)