Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 117
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-1acd8f0b76ab00e36b53cc3ca90b72b2199f3388b3e307890adb464b87f9a2d8.yml
openapi_spec_hash: 82003125c1c2c5d82d19270bafb4a6ca
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-2d1337eec44e036b9c896b7db4691f0a12edfa79d3f28b611818bcedf62d44ee.yml
openapi_spec_hash: 30110dbbe733b16e40a6d0aa41d0c8c4
config_hash: ede72e4ae65cc5a6d6927938b3455c46
30 changes: 30 additions & 0 deletions examples/browser-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Kernel from '@onkernel/sdk';

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

// Create a browser with telemetry enabled so it emits events while it runs.
const browser = await kernel.browsers.create({ telemetry: { enabled: true } });

try {
// Telemetry is a default routing subresource, so the stream goes directly to the VM automatically.
const stream = await kernel.browsers.telemetry.stream(browser.session_id);

// Make browser activity to generate telemetry. The "api" category emits an event per VM API call,
// so events arrive within ~1s.
for (let i = 0; i < 3; i++) {
await kernel.browsers.curl(browser.session_id, { url: 'https://example.com', method: 'GET' });
}

// Print a few events, then stop so the program terminates promptly.
let count = 0;
for await (const event of stream) {
console.log('telemetry event', event);
if (++count >= 3) break;
}
} finally {
await kernel.browsers.deleteByID(browser.session_id);
}
}

void main();
82 changes: 79 additions & 3 deletions src/lib/browser-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,62 @@ export class BrowserRouteCache {
}

const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES';
const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl'];
const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl', 'telemetry'];
const BROWSER_ROUTE_CACHEABLE_PATH = /^\/(?:v\d+\/)?browsers(?:\/[^/]+)?\/?$/;
const BROWSER_POOL_ACQUIRE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/acquire\/?$/;
const BROWSER_DELETE_BY_ID_PATH = /^\/(?:v\d+\/)?browsers\/([^/]+)\/?$/;
const BROWSER_POOL_RELEASE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/release\/?$/;

/**
* Registry of routed (subresource + suffix) paths that are eligible for
* control-plane fallback when the VM reports the browser is authoritatively
* gone (HTTP 404 with body code "browser_gone"). Everything not listed here is
* fallback-OFF by default.
*
* Adding a future eligible endpoint is intentionally a one-line edit: append
* another `${subresource} ${suffix}` entry below.
*/
const FALLBACK_ELIGIBLE_ROUTES = new Set<string>([
// PROSPECTIVE: GET /browsers/{id}/telemetry/events. This pull endpoint /
// method does not exist on the SDK yet; this entry pre-wires the opt-in so
// control-plane fallback works the moment the method ships. Remove this
// comment once the method lands.
fallbackRouteKey('telemetry', '/events'),
]);

const BROWSER_GONE_CODE = 'browser_gone';

function fallbackRouteKey(subresource: string, suffix: string): string {
return `${subresource} ${suffix}`;
}

/**
* Whether a routed path (parsed subresource + suffix) is opted in to
* control-plane fallback. Suffix is the portion after the subresource (e.g.
* "/events"), or "" when the request targets the bare subresource.
*/
export function isFallbackEligible(subresource: string, suffix: string): boolean {
return FALLBACK_ELIGIBLE_ROUTES.has(fallbackRouteKey(subresource, suffix));
}

async function isBrowserGone404(response: Response): Promise<boolean> {
if (response.status !== 404) {
return false;
}
// Key off the body code only (per kernel#2317: there is NO special response
// header). We do not gate on content-type so behavior matches the spec and
// the python SDK, which simply attempts response.json(). A non-JSON body just
// fails to parse and returns false.
try {
const body = await response.clone().json();
return (
!!body && typeof body === 'object' && (body as Record<string, unknown>)['code'] === BROWSER_GONE_CODE
);
} catch {
return false;
}
}

export function browserRoutingSubresourcesFromEnv(): string[] {
const raw = readBrowserRoutingSubresourcesEnv();
if (raw === undefined) {
Expand Down Expand Up @@ -229,6 +279,7 @@ async function routeRequest(

const sessionId = decodeURIComponent(match[1] ?? '');
const subresource = match[2] ?? '';
const suffix = match[3] ?? '';
if (!sessionId || !allowed.has(subresource)) {
return innerFetch(input, init);
}
Expand All @@ -237,7 +288,7 @@ async function routeRequest(
return innerFetch(input, init);
}

const target = new URL(joinURL(route.baseURL, `/${subresource}${match[3] ?? ''}`));
const target = new URL(joinURL(route.baseURL, `/${subresource}${suffix}`));
url.searchParams.forEach((value, key) => {
if (key !== 'jwt') {
target.searchParams.append(key, value);
Expand All @@ -249,7 +300,32 @@ async function routeRequest(

const headers = new Headers(request.headers);
headers.delete('authorization');
return innerFetch(target.toString(), buildRoutedInit(request, init, headers));
const response = await innerFetch(target.toString(), buildRoutedInit(request, init, headers));

// Control-plane fallback: the request was actually routed to the VM, so this
// is the only place we attempt it. Fall back IFF the method is GET, the routed
// path is opted in, and the VM authoritatively reports the browser is gone
// (HTTP 404 with body code "browser_gone"). Everything else — success,
// transient 5xx, network errors, other 4xx, or a 404 without that code —
// propagates unchanged.
const method = request.method.toUpperCase();
if (method !== 'GET' || !isFallbackEligible(subresource, suffix)) {
return response;
}
if (!(await isBrowserGone404(response))) {
return response;
}

// The browser is authoritatively gone: evict the now-stale route and re-issue
// the ORIGINAL request to the control plane exactly once. Restore the original
// Authorization (still present on `request.headers`) and drop the jwt query
// param so we hit the CP, not the VM. Never loops back through routing.
cache.delete(sessionId);

const cpURL = new URL(request.url);
cpURL.searchParams.delete('jwt');
const cpHeaders = new Headers(request.headers);
return innerFetch(cpURL.toString(), buildRoutedInit(request, init, cpHeaders));
}

function buildRoutedInit(
Expand Down
15 changes: 7 additions & 8 deletions src/resources/browser-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export class BrowserPools extends APIResource {
* ```ts
* const browserPool = await client.browserPools.update(
* 'id_or_name',
* { size: 10 },
* );
* ```
*/
Expand Down Expand Up @@ -494,13 +493,6 @@ export interface BrowserPoolCreateParams {
}

export interface BrowserPoolUpdateParams {
/**
* Number of browsers to maintain in the pool. The maximum size is determined by
* your organization's pooled sessions limit (the sum of all pool sizes cannot
* exceed your limit).
*/
size: number;

/**
* Custom Chrome enterprise policy overrides applied to all browsers in this pool.
* Keys are Chrome enterprise policy names; values must match their expected types.
Expand Down Expand Up @@ -554,6 +546,13 @@ export interface BrowserPoolUpdateParams {
*/
proxy_id?: string;

/**
* Number of browsers to maintain in the pool. The maximum size is determined by
* your organization's pooled sessions limit (the sum of all pool sizes cannot
* exceed your limit).
*/
size?: number;

/**
* Optional URL to navigate to when a new browser is warmed into the pool.
* Best-effort: failures to navigate do not fail pool fill. Only applied to
Expand Down
32 changes: 2 additions & 30 deletions tests/api-resources/browser-pools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ describe('resource browserPools', () => {
});

// Mock server tests are disabled
test.skip('update: only required params', async () => {
const responsePromise = client.browserPools.update('id_or_name', { size: 10 });
test.skip('update', async () => {
const responsePromise = client.browserPools.update('id_or_name', {});
const rawResponse = await responsePromise.asResponse();
expect(rawResponse).toBeInstanceOf(Response);
const response = await responsePromise;
Expand All @@ -71,34 +71,6 @@ describe('resource browserPools', () => {
expect(dataAndResponse.response).toBe(rawResponse);
});

// Mock server tests are disabled
test.skip('update: required and optional params', async () => {
const response = await client.browserPools.update('id_or_name', {
size: 10,
chrome_policy: { foo: 'bar' },
discard_all_idle: false,
extensions: [{ id: 'id', name: 'name' }],
fill_rate_per_minute: 0,
headless: false,
kiosk_mode: true,
name: 'my-pool',
profile: {
id: 'id',
name: 'name',
save_changes: true,
},
proxy_id: 'proxy_id',
start_url: 'https://example.com',
stealth: true,
timeout_seconds: 60,
viewport: {
height: 800,
width: 1280,
refresh_rate: 60,
},
});
});

// Mock server tests are disabled
test.skip('list', async () => {
const responsePromise = client.browserPools.list();
Expand Down
Loading
Loading