Skip to content

Commit 9b24280

Browse files
committed
fix: preserve browser routing fetch options
Keep the routing wrapper from stripping runtime-specific fetch init options when requests fall through or route directly to the VM, and share the browser fetch helpers so routed methods stay type-safe and covered by regression tests. Made-with: Cursor
1 parent 00c91ef commit 9b24280

5 files changed

Lines changed: 123 additions & 21 deletions

File tree

src/internal/types.ts

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

33
export type PromiseOrValue<T> = T | Promise<T>;
4-
export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
4+
export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options';
55

66
export type KeysEnum<T> = { [P in keyof Required<T>]: true };
77

@@ -64,15 +64,19 @@ type OverloadedParameters<T> =
6464
* [1]: https://www.typescriptlang.org/tsconfig/#typeAcquisition
6565
*/
6666
/** @ts-ignore For users with \@types/node */
67+
// prettier-ignore
6768
type UndiciTypesRequestInit = NotAny<import('../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../../node_modules/undici-types/index.d.ts').RequestInit>;
6869
/** @ts-ignore For users with undici */
70+
// prettier-ignore
6971
type UndiciRequestInit = NotAny<import('../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../../node_modules/undici/index.d.ts').RequestInit>;
7072
/** @ts-ignore For users with \@types/bun */
7173
type BunRequestInit = globalThis.FetchRequestInit;
7274
/** @ts-ignore For users with node-fetch@2 */
75+
// prettier-ignore
7376
type NodeFetch2RequestInit = NotAny<import('../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit>;
7477
/** @ts-ignore For users with node-fetch@3, doesn't need file extension because types are at ./@types/index.d.ts */
75-
type NodeFetch3RequestInit = NotAny<import('../node_modules/node-fetch').RequestInit> | NotAny<import('../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../../../../node_modules/node-fetch').RequestInit>;
78+
// prettier-ignore
79+
type NodeFetch3RequestInit = NotAny<import('../node_modules/node-fetch').RequestInit> | NotAny<import('../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../../../../node_modules/node-fetch').RequestInit>;
7680
/** @ts-ignore For users who use Deno */
7781
type FetchRequestInit = NonNullable<OverloadedParameters<typeof fetch>[1]>;
7882
/* eslint-enable */

src/internal/utils/url.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function joinURL(baseURL: string, path: string): string {
2+
return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
3+
}

src/lib/browser-fetch.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { KernelError } from '../core/error';
33
import { buildHeaders } from '../internal/headers';
44
import type { FinalRequestOptions, RequestOptions } from '../internal/request-options';
55
import type { HTTPMethod } from '../internal/types';
6+
import { joinURL } from '../internal/utils/url';
67
import type { Kernel } from '../client';
78

89
export interface BrowserFetchInit extends RequestInit {
@@ -177,7 +178,3 @@ function headersToRequestOptionsHeaders(headers: Headers): Record<string, string
177178

178179
return out;
179180
}
180-
181-
function joinURL(baseURL: string, path: string): string {
182-
return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
183-
}

src/lib/browser-routing.ts

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { Fetch } from '../internal/builtin-types';
1+
import type { Fetch, RequestInfo, RequestInit } from '../internal/builtin-types';
2+
import { joinURL } from '../internal/utils/url';
23

34
export type BrowserRoute = {
45
sessionId: string;
@@ -62,7 +63,7 @@ export function createRoutingFetch(
6263

6364
return async (input, init) => {
6465
const request = new Request(input, init);
65-
const response = await routeRequest(innerFetch, request, apiOrigin, allowed, cache);
66+
const response = await routeRequest(innerFetch, { input, init, request }, apiOrigin, allowed, cache);
6667
await sniffAndPopulateCache(response, cache);
6768
return response;
6869
};
@@ -132,29 +133,37 @@ function populateCache(value: unknown, cache: BrowserRouteCache): void {
132133

133134
async function routeRequest(
134135
innerFetch: Fetch,
135-
request: Request,
136+
{
137+
input,
138+
init,
139+
request,
140+
}: {
141+
input: RequestInfo;
142+
init: RequestInit | undefined;
143+
request: Request;
144+
},
136145
apiOrigin: string,
137146
allowed: ReadonlySet<string>,
138147
cache: BrowserRouteCache,
139148
): Promise<Response> {
140149
const url = new URL(request.url);
141150
if (url.origin !== apiOrigin) {
142-
return innerFetch(request);
151+
return innerFetch(input, init);
143152
}
144153

145154
const match = url.pathname.match(/^\/(?:v\d+\/)?browsers\/([^/]+)\/([^/]+)(\/.*)?$/);
146155
if (!match) {
147-
return innerFetch(request);
156+
return innerFetch(input, init);
148157
}
149158

150159
const sessionId = decodeURIComponent(match[1] ?? '');
151160
const subresource = match[2] ?? '';
152161
if (!sessionId || !allowed.has(subresource)) {
153-
return innerFetch(request);
162+
return innerFetch(input, init);
154163
}
155164
const route = cache.get(sessionId);
156165
if (route === undefined) {
157-
return innerFetch(request);
166+
return innerFetch(input, init);
158167
}
159168

160169
const target = new URL(joinURL(route.baseURL, `/${subresource}${match[3] ?? ''}`));
@@ -169,23 +178,57 @@ async function routeRequest(
169178

170179
const headers = new Headers(request.headers);
171180
headers.delete('authorization');
181+
return innerFetch(target.toString(), buildRoutedInit(request, init, headers));
182+
}
183+
184+
function buildRoutedInit(
185+
request: Request,
186+
originalInit: RequestInit | undefined,
187+
headers: Headers,
188+
): RequestInit {
172189
const method = request.method.toUpperCase();
173-
const init: RequestInit = {
190+
const routedInit = {
191+
...((originalInit ?? {}) as Record<string, unknown>),
174192
method,
175193
headers,
176194
redirect: request.redirect,
177195
signal: request.signal,
178-
};
179-
if (method !== 'GET' && method !== 'HEAD' && request.body) {
180-
init.body = request.body;
181-
init.duplex = 'half';
196+
} as RequestInit & Record<string, unknown>;
197+
198+
delete routedInit['body'];
199+
delete routedInit['duplex'];
200+
201+
if (method !== 'GET' && method !== 'HEAD') {
202+
const body = requestBodyForFetch(request, originalInit);
203+
if (body !== undefined) {
204+
routedInit.body = body;
205+
}
206+
if (originalInit?.duplex !== undefined) {
207+
routedInit.duplex = originalInit.duplex;
208+
} else if (requiresHalfDuplex(body)) {
209+
routedInit.duplex = 'half';
210+
}
211+
}
212+
213+
return routedInit;
214+
}
215+
216+
function requestBodyForFetch(
217+
request: Request,
218+
originalInit: RequestInit | undefined,
219+
): RequestInit['body'] | undefined {
220+
if (originalInit?.body !== undefined && originalInit.body !== null) {
221+
return originalInit.body;
182222
}
183223

184-
return innerFetch(new Request(target.toString(), init));
224+
return request.body ?? undefined;
185225
}
186226

187-
function joinURL(baseURL: string, path: string): string {
188-
return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
227+
function requiresHalfDuplex(body: RequestInit['body'] | undefined): boolean {
228+
return (
229+
((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream) ||
230+
(typeof body === 'object' && body !== null && Symbol.asyncIterator in body)
231+
);
189232
}
190233

191234
function parseJwtFromCdpWsUrl(cdpWsUrl: string | undefined): string | undefined {

tests/lib/browser-routing.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,36 @@ describe('browser routing', () => {
127127
});
128128
});
129129

130+
test('preserves custom fetch options for both API and routed VM requests', async () => {
131+
await withBrowserRoutingEnv('process', async () => {
132+
const dispatcher = Symbol('dispatcher');
133+
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
134+
const kernel = new Kernel({
135+
apiKey: 'k',
136+
baseURL: 'https://api.example/',
137+
fetchOptions: { dispatcher } as any,
138+
fetch: async (input, init?: RequestInit) => {
139+
const url = normalizeURL(input);
140+
calls.push({ url, init });
141+
if (url === 'https://api.example/browsers') {
142+
return Response.json({
143+
session_id: 'sess-1',
144+
base_url: 'http://browser-session.test/browser/kernel',
145+
cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc',
146+
});
147+
}
148+
return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' });
149+
},
150+
});
151+
152+
await kernel.browsers.create();
153+
await kernel.browsers.process.exec('sess-1', { command: 'echo' });
154+
155+
expect((calls[0]?.init as any)?.dispatcher).toBe(dispatcher);
156+
expect((calls[1]?.init as any)?.dispatcher).toBe(dispatcher);
157+
});
158+
});
159+
130160
test('ignores browser responses that do not include a usable jwt', async () => {
131161
await withBrowserRoutingEnv('process', async () => {
132162
const kernel = new Kernel({
@@ -173,6 +203,31 @@ describe('browser routing', () => {
173203
await expect(kernel.browsers.fetch('sess-1', 'https://example.com/again')).rejects.toThrow(/route cache/);
174204
});
175205

206+
test('browser.fetch supports HEAD requests', async () => {
207+
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
208+
const kernel = new Kernel({
209+
apiKey: 'k',
210+
baseURL: 'https://api.example/',
211+
fetch: async (input, init?: RequestInit) => {
212+
const url = normalizeURL(input);
213+
calls.push({ url, init });
214+
return new Response(null, { status: 204 });
215+
},
216+
});
217+
218+
kernel.browserRouteCache.set({
219+
sessionId: 'sess-1',
220+
baseURL: 'http://browser-session.test/browser/kernel',
221+
jwt: 'token-abc',
222+
});
223+
224+
const response = await kernel.browsers.fetch('sess-1', 'https://example.com/hello', { method: 'HEAD' });
225+
226+
expect(response.status).toBe(204);
227+
expect(calls[0]?.url).toContain('http://browser-session.test/browser/kernel/curl/raw?');
228+
expect(calls[0]?.init?.method).toBe('HEAD');
229+
});
230+
176231
test('defaults browser routing subresources to curl when env is unset', async () => {
177232
await withBrowserRoutingEnv(undefined, async () => {
178233
expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl']);

0 commit comments

Comments
 (0)