Skip to content

Commit 81e47ca

Browse files
committed
fix: handle browser pool route cache updates
Warm the browser route cache from browser pool acquire responses and evict released sessions after successful pool releases. Keep this behavior in the routing middleware so generated resource methods stay untouched. Made-with: Cursor
1 parent a7ff9bc commit 81e47ca

2 files changed

Lines changed: 123 additions & 10 deletions

File tree

src/lib/browser-routing.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ export class BrowserRouteCache {
3030
const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES';
3131
const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl'];
3232
const BROWSER_ROUTE_CACHEABLE_PATH = /^\/(?:v\d+\/)?browsers(?:\/[^/]+)?\/?$/;
33+
const BROWSER_POOL_ACQUIRE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/acquire\/?$/;
3334
const BROWSER_DELETE_BY_ID_PATH = /^\/(?:v\d+\/)?browsers\/([^/]+)\/?$/;
35+
const BROWSER_POOL_RELEASE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/release\/?$/;
3436

3537
export function browserRoutingSubresourcesFromEnv(): string[] {
3638
const raw = readBrowserRoutingSubresourcesEnv();
@@ -70,23 +72,26 @@ export function createRoutingFetch(
7072
if (shouldSniff) {
7173
await sniffAndPopulateCache(response, cache);
7274
}
73-
maybeEvictDeletedBrowserRoute(request, response, apiOrigin, cache);
75+
await maybeEvictBrowserRoute(request, response, apiOrigin, cache);
7476
return response;
7577
};
7678
}
7779

7880
function shouldSniffAndPopulateCache(request: Request, apiOrigin: string): boolean {
7981
const url = new URL(request.url);
80-
return url.origin === apiOrigin && BROWSER_ROUTE_CACHEABLE_PATH.test(url.pathname);
82+
return (
83+
url.origin === apiOrigin &&
84+
(BROWSER_ROUTE_CACHEABLE_PATH.test(url.pathname) || BROWSER_POOL_ACQUIRE_PATH.test(url.pathname))
85+
);
8186
}
8287

83-
function maybeEvictDeletedBrowserRoute(
88+
async function maybeEvictBrowserRoute(
8489
request: Request,
8590
response: Response,
8691
apiOrigin: string,
8792
cache: BrowserRouteCache,
88-
): void {
89-
if (!response.ok || request.method.toUpperCase() !== 'DELETE') {
93+
): Promise<void> {
94+
if (!response.ok) {
9095
return;
9196
}
9297

@@ -95,17 +100,44 @@ function maybeEvictDeletedBrowserRoute(
95100
return;
96101
}
97102

98-
const match = url.pathname.match(BROWSER_DELETE_BY_ID_PATH);
103+
const sessionId =
104+
deletedSessionIdFromPath(request, url.pathname) ??
105+
(await releasedSessionIdFromRequest(request, url.pathname));
106+
if (sessionId) {
107+
cache.delete(sessionId);
108+
}
109+
}
110+
111+
function deletedSessionIdFromPath(request: Request, pathname: string): string | undefined {
112+
if (request.method.toUpperCase() !== 'DELETE') {
113+
return undefined;
114+
}
115+
116+
const match = pathname.match(BROWSER_DELETE_BY_ID_PATH);
99117
if (!match) {
100-
return;
118+
return undefined;
101119
}
102120

103121
const sessionId = decodeURIComponent(match[1] ?? '');
104-
if (!sessionId) {
105-
return;
122+
return sessionId || undefined;
123+
}
124+
125+
async function releasedSessionIdFromRequest(request: Request, pathname: string): Promise<string | undefined> {
126+
if (request.method.toUpperCase() !== 'POST' || !BROWSER_POOL_RELEASE_PATH.test(pathname)) {
127+
return undefined;
106128
}
107129

108-
cache.delete(sessionId);
130+
try {
131+
const body = await request.clone().json();
132+
if (!body || typeof body !== 'object') {
133+
return undefined;
134+
}
135+
136+
const sessionId = (body as Record<string, unknown>)['session_id'];
137+
return typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : undefined;
138+
} catch {
139+
return undefined;
140+
}
109141
}
110142

111143
function browserRouteFromValue(value: unknown): BrowserRoute | undefined {

tests/lib/browser-routing.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,41 @@ describe('browser routing', () => {
233233
await expect(kernel.browsers.fetch('sess-1', 'https://example.com/again')).rejects.toThrow(/route cache/);
234234
});
235235

236+
test('warms cache from browser pool acquire responses', async () => {
237+
await withBrowserRoutingEnv('process', async () => {
238+
const calls: string[] = [];
239+
const kernel = new Kernel({
240+
apiKey: 'k',
241+
baseURL: 'https://api.example/',
242+
fetch: async (input) => {
243+
const url = normalizeURL(input);
244+
calls.push(url);
245+
if (url === 'https://api.example/browser_pools/pool-1/acquire') {
246+
return Response.json({
247+
session_id: 'sess-1',
248+
base_url: 'http://browser-session.test/browser/kernel',
249+
cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc',
250+
});
251+
}
252+
return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' });
253+
},
254+
});
255+
256+
await kernel.browserPools.acquire('pool-1', {});
257+
await kernel.browsers.process.exec('sess-1', { command: 'echo' });
258+
259+
expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({
260+
sessionId: 'sess-1',
261+
baseURL: 'http://browser-session.test/browser/kernel',
262+
jwt: 'token-abc',
263+
});
264+
expect(calls).toEqual([
265+
'https://api.example/browser_pools/pool-1/acquire',
266+
'http://browser-session.test/browser/kernel/process/exec?jwt=token-abc',
267+
]);
268+
});
269+
});
270+
236271
test('evicts cached route after successful browser delete by id', async () => {
237272
const calls: string[] = [];
238273
const kernel = new Kernel({
@@ -257,6 +292,30 @@ describe('browser routing', () => {
257292
expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined();
258293
});
259294

295+
test('evicts cached route after successful browser pool release', async () => {
296+
const calls: string[] = [];
297+
const kernel = new Kernel({
298+
apiKey: 'k',
299+
baseURL: 'https://api.example/',
300+
fetch: async (input) => {
301+
const url = normalizeURL(input);
302+
calls.push(url);
303+
return new Response(null, { status: 204 });
304+
},
305+
});
306+
307+
kernel.browserRouteCache.set({
308+
sessionId: 'sess-1',
309+
baseURL: 'http://browser-session.test/browser/kernel',
310+
jwt: 'token-abc',
311+
});
312+
313+
await kernel.browserPools.release('pool-1', { session_id: 'sess-1' });
314+
315+
expect(calls).toEqual(['https://api.example/browser_pools/pool-1/release']);
316+
expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined();
317+
});
318+
260319
test('keeps cached route when browser delete by id fails', async () => {
261320
const kernel = new Kernel({
262321
apiKey: 'k',
@@ -279,6 +338,28 @@ describe('browser routing', () => {
279338
});
280339
});
281340

341+
test('keeps cached route when browser pool release fails', async () => {
342+
const kernel = new Kernel({
343+
apiKey: 'k',
344+
baseURL: 'https://api.example/',
345+
maxRetries: 0,
346+
fetch: async () => new Response('boom', { status: 500, headers: { 'content-type': 'text/plain' } }),
347+
});
348+
349+
kernel.browserRouteCache.set({
350+
sessionId: 'sess-1',
351+
baseURL: 'http://browser-session.test/browser/kernel',
352+
jwt: 'token-abc',
353+
});
354+
355+
await expect(kernel.browserPools.release('pool-1', { session_id: 'sess-1' })).rejects.toThrow();
356+
expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({
357+
sessionId: 'sess-1',
358+
baseURL: 'http://browser-session.test/browser/kernel',
359+
jwt: 'token-abc',
360+
});
361+
});
362+
282363
test('browser.fetch rejects methods outside the SDK HTTPMethod union', async () => {
283364
const kernel = new Kernel({
284365
apiKey: 'k',

0 commit comments

Comments
 (0)