Skip to content
Open

[wip] #100

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
136 changes: 136 additions & 0 deletions examples/browser-routing-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Smoke test for the demo metro-direct routing middleware.
*
* Runs the local source (not the published build) thanks to the tsconfig path
* alias `@onkernel/sdk` -> `./src/index.ts`, wired up by `yarn tsn`.
*
* Usage (from the repo root):
*
* cd /Users/sayan/kernel/kernel-node-sdk
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded personal filesystem path in example file

Low Severity

The usage comment contains a hardcoded personal directory path (/Users/sayan/kernel/kernel-node-sdk). This leaks a developer's local filesystem layout and isn't useful for other developers.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9aab517. Configure here.

* yarn install # if you haven't already
* KERNEL_API_KEY=sk-... yarn tsn examples/browser-routing-smoke.ts
*
* Optional env vars:
* KERNEL_BASE_URL - override the API base (defaults to production)
* SKIP_COMPARE - if set, skip the public-API timing comparison
*
* What this verifies:
* 1. browsers.create() returns a Browser whose base_url + cdp_ws_url
* let us derive a metro-direct route.
* 2. The routing cache gets populated automatically (no manual prewarm).
* 3. A subresource call (computer.clickMouse) actually succeeds when
* routed to <base_url>/computer/click_mouse?jwt=...
* 4. (Optional) timing comparison vs. the public-API path.
*
* If anything fails, the browser is still cleaned up.
*/

import Kernel from '@onkernel/sdk';

const SUBSEP = '─'.repeat(60);

function log(...args: unknown[]) {
console.log(...args);
}
function header(s: string) {
console.log('\n' + SUBSEP + '\n' + s + '\n' + SUBSEP);
}

async function timeIt<T>(label: string, fn: () => Promise<T>): Promise<{ value: T; ms: number }> {
const t0 = Date.now();
const value = await fn();
const ms = Date.now() - t0;
log(` ${label}: ${ms} ms`);
return { value, ms };
}

async function main() {
if (!process.env['KERNEL_API_KEY']) {
console.error('Set KERNEL_API_KEY before running this script.');
process.exit(2);
}

// Routed client (opt-in to metro-direct).
const routed = new Kernel({
browserRouting: { enabled: true },
logLevel: 'debug',
});

// Plain client for the side-by-side comparison; same API key, no routing.
const plain = new Kernel({ logLevel: 'warn' });

header('1) Create a browser and inspect routing-relevant fields');
const browser = await routed.browsers.create({});
log(' session_id:', browser.session_id);
log(' base_url: ', browser.base_url ?? '<empty>');
log(' cdp_ws_url:', browser.cdp_ws_url);

let exitCode = 0;
try {
header('2) Verify cache was populated by the create response');
const cached = routed.browserRouteCache?.get(browser.session_id);
log(' cache entry:', cached);
if (!cached) {
console.error(
' FAIL: cache was not populated. Either base_url is empty in this env,',
'\n or cdp_ws_url has no `?jwt=` query param.',
);
exitCode = 1;
return;
}
if (!browser.base_url) {
console.error(' FAIL: base_url was empty even though we cached something — bug in extractor.');
exitCode = 1;
return;
}

header('3) Call computer.clickMouse via metro-direct (watch debug log)');
const routedCall = await timeIt('metro-direct call', () =>
routed.browsers.computer.clickMouse(browser.session_id, { x: 10, y: 10 }),
);
void routedCall;

if (!process.env['SKIP_COMPARE']) {
header('4) Same call via the public API for comparison');
const plainCall = await timeIt('public-API call', () =>
plain.browsers.computer.clickMouse(browser.session_id, { x: 20, y: 20 }),
);
void plainCall;

header('5) Repeat both, 3x each, to get a steady-state read');
const routedSamples: number[] = [];
const plainSamples: number[] = [];
for (let i = 0; i < 3; i++) {
const r = await timeIt(`metro-direct #${i + 1}`, () =>
routed.browsers.computer.clickMouse(browser.session_id, { x: 30 + i, y: 30 + i }),
);
routedSamples.push(r.ms);
const p = await timeIt(`public-API #${i + 1}`, () =>
plain.browsers.computer.clickMouse(browser.session_id, { x: 40 + i, y: 40 + i }),
);
plainSamples.push(p.ms);
}
const avg = (xs: number[]) => Math.round(xs.reduce((a, b) => a + b, 0) / xs.length);
header('6) Result');
log(` metro-direct avg: ${avg(routedSamples)} ms (samples: ${routedSamples.join(', ')})`);
log(` public-API avg: ${avg(plainSamples)} ms (samples: ${plainSamples.join(', ')})`);
log(` delta: ${avg(plainSamples) - avg(routedSamples)} ms`);
}

log('\nOK');
} catch (err) {
console.error('\nERROR during routed flow:', err);
exitCode = 1;
} finally {
header('cleanup');
try {
await plain.browsers.deleteByID(browser.session_id);
log(' deleted', browser.session_id);
} catch (e) {
console.error(' failed to delete browser:', e);
}
process.exit(exitCode);
}
}

void main();
22 changes: 22 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AbstractPage, type OffsetPaginationParams, OffsetPaginationResponse } f
import * as Uploads from './core/uploads';
import * as API from './resources/index';
import { APIPromise } from './core/api-promise';
import { BrowserRouteCache, createRoutingFetch } from './lib/browser-routing';
import { AppListParams, AppListResponse, AppListResponsesOffsetPagination, Apps } from './resources/apps';
import {
BrowserPool,
Expand Down Expand Up @@ -231,6 +232,17 @@ export interface ClientOptions {
* Defaults to globalThis.console.
*/
logger?: Logger | undefined;

/**
* Opt in to transparent metro-direct routing for browser subresource calls.
* When enabled, calls like `browsers.process.exec(id, ...)` are routed
* directly to the metro-api proxy when the SDK has seen a Browser response
* for `id` in the current process. Falls back transparently to the public
* API on cache miss or on 401/403/404 from metro.
*
* Demo flag — off by default to keep the default behavior unchanged.
*/
browserRouting?: { enabled?: boolean; cache?: BrowserRouteCache } | undefined;
}

/**
Expand All @@ -247,6 +259,8 @@ export class Kernel {
fetchOptions: MergedRequestInit | undefined;

private fetch: Fetch;
/** Exposed for debugging/demo — inspect or prewarm the metro-direct route cache. */
public browserRouteCache?: BrowserRouteCache;
#encoder: Opts.RequestEncoder;
protected idempotencyHeader?: string;
private _options: ClientOptions;
Expand Down Expand Up @@ -313,6 +327,14 @@ export class Kernel {
this.fetchOptions = options.fetchOptions;
this.maxRetries = options.maxRetries ?? 2;
this.fetch = options.fetch ?? Shims.getDefaultFetch();
if (options.browserRouting?.enabled) {
this.browserRouteCache = options.browserRouting.cache ?? new BrowserRouteCache();
this.fetch = createRoutingFetch({
apiBaseURL: this.baseURL,
inner: this.fetch,
cache: this.browserRouteCache,
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Routing fetch double-wraps on withOptions calls

Medium Severity

When browserRouting is enabled and withOptions() is called, the routing fetch gets double-wrapped. withOptions passes fetch: this.fetch (the already-wrapped routing fetch), while this._options still carries browserRouting: { enabled: true }. The new constructor then wraps the already-wrapped fetch with another createRoutingFetch layer. Each withOptions call adds another nested routing layer, each with its own separate BrowserRouteCache, causing duplicate response cloning/parsing and cache isolation.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9aab517. Configure here.

this.#encoder = Opts.FallbackEncoder;

this._options = options;
Expand Down
Loading
Loading