From 9aab51787bfd623e4cd8772c0f27772e7a0de673 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Tue, 21 Apr 2026 15:23:31 -0700 Subject: [PATCH] vibe code --- examples/browser-routing-smoke.ts | 136 ++++++++++++++++ src/client.ts | 22 +++ src/lib/browser-routing.ts | 248 +++++++++++++++++++++++++++++ tests/lib/browser-routing.test.ts | 251 ++++++++++++++++++++++++++++++ yarn.lock | 19 +++ 5 files changed, 676 insertions(+) create mode 100644 examples/browser-routing-smoke.ts create mode 100644 src/lib/browser-routing.ts create mode 100644 tests/lib/browser-routing.test.ts diff --git a/examples/browser-routing-smoke.ts b/examples/browser-routing-smoke.ts new file mode 100644 index 00000000..f4a39ec4 --- /dev/null +++ b/examples/browser-routing-smoke.ts @@ -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 + * 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 /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(label: string, fn: () => Promise): 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 ?? ''); + 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(); diff --git a/src/client.ts b/src/client.ts index 7a00e5e5..ad0fcf4d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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, @@ -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; } /** @@ -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; @@ -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, + }); + } this.#encoder = Opts.FallbackEncoder; this._options = options; diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts new file mode 100644 index 00000000..d809e3fb --- /dev/null +++ b/src/lib/browser-routing.ts @@ -0,0 +1,248 @@ +/** + * Demo: dynamic metro-direct routing for browser subresource calls. + * + * This is a proof-of-concept for how the Node SDK could transparently route + * `/browsers/{id}//...` calls directly to metro-api instead of hopping + * through the public API, without changing any user-facing API surface. + * + * Shape of the idea: + * 1. Intercept the SDK's `fetch` call. + * 2. Keep a per-client in-memory cache of `{ id -> { baseURL, jwt } }`. + * 3. On outgoing requests to `/browsers/{id}/`, + * rewrite the URL to `/?jwt=` and strip + * the Authorization header. + * 4. On incoming JSON responses that look like a Browser (have `session_id` + * + `cdp_ws_url`), populate the cache as a side effect. So the common + * case of "create a browser, then use it" warms the cache for free. + * 5. If the metro-direct call returns 401/403/404, evict the cache entry + * and retry once against the public API so the caller never sees a + * transient failure caused by our rewrite. + * + * Not implemented here (noted as TODOs so the shape is obvious): + * - Lazy-fill on cache miss via `GET /browsers/{id}` (with single-flight). + * - Driving the allowlist from `x-metro-direct` in the OpenAPI spec instead + * of a hardcoded set. That's a codegen change, orthogonal to this runtime. + * - True LRU eviction (the demo uses insertion-order + size cap). + * - JWT expiry tracking (would normally come from the JWT claims or a TTL + * field on the Browser response). + */ + +import type { Fetch } from '../internal/builtin-types'; + +export interface BrowserRoute { + baseURL: string; + jwt: string; + // Expiration; `undefined` means we don't know and we just trust until 401. + expiresAt?: number; +} + +export class BrowserRouteCache { + private readonly entries = new Map(); + constructor(private readonly maxEntries = 1024) {} + + get(id: string): BrowserRoute | undefined { + const e = this.entries.get(id); + if (!e) return undefined; + if (e.expiresAt !== undefined && e.expiresAt < Date.now()) { + this.entries.delete(id); + return undefined; + } + return e; + } + + set(id: string, route: BrowserRoute): void { + if (this.entries.size >= this.maxEntries && !this.entries.has(id)) { + // Evict the oldest entry (insertion order). + const oldest = this.entries.keys().next().value; + if (oldest !== undefined) this.entries.delete(oldest); + } + this.entries.set(id, route); + } + + evict(id: string): void { + this.entries.delete(id); + } + + size(): number { + return this.entries.size; + } +} + +/** + * Allowlist of browser subresource path segments that are safe to serve + * directly from metro-api. In the real design this would be generated at + * codegen time from `x-metro-direct: true` markers in the OpenAPI spec. + * + * Notably excluded: + * - `extensions`: DB-backed on the public API, no in-VM equivalent. + * - `replays`: path semantics differ between public API and metro. + */ +export const METRO_DIRECT_SUBRESOURCES: ReadonlySet = new Set([ + 'process', + 'fs', + 'computer', + 'playwright', + 'curl', + 'logs', +]); + +const BROWSER_PATH_RE = /^\/(?:v\d+\/)?browsers\/([^/?#]+)\/([^/?#]+)(\/.*)?$/; + +export interface RoutingFetchOptions { + /** API base URL that the SDK would otherwise hit (used to scope rewrite). */ + apiBaseURL: string; + /** The fetch to delegate to (usually the user-provided or global fetch). */ + inner: Fetch; + /** Shared routing cache. */ + cache: BrowserRouteCache; + /** Optional logger for debugging / observability. */ + logger?: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }; +} + +type RouteMatch = + | { kind: 'route'; id: string; subresource: string; rest: string } + | { kind: 'skip' }; + +function matchBrowserSubresource(url: URL, apiBaseURL: URL): RouteMatch { + // Only rewrite requests that were aimed at the configured public API. + if (url.origin !== apiBaseURL.origin) return { kind: 'skip' }; + + const m = BROWSER_PATH_RE.exec(url.pathname); + if (!m) return { kind: 'skip' }; + const [, id, subresource, rest] = m; + if (!METRO_DIRECT_SUBRESOURCES.has(subresource!)) return { kind: 'skip' }; + return { kind: 'route', id: id!, subresource: subresource!, rest: rest ?? '' }; +} + +function rewriteRequest( + originalUrl: URL, + route: BrowserRoute, + subresource: string, + rest: string, + init: RequestInit | undefined, +): { url: string; init: RequestInit } { + // `route.baseURL` already includes the `/browser/kernel` prefix + // (e.g. https://proxy.yul-upbeat-herschel.onkernel.com:8443/browser/kernel). + const target = new URL(route.baseURL.replace(/\/$/, '') + '/' + subresource + rest); + + // Preserve any query params the caller set. + originalUrl.searchParams.forEach((v, k) => { + if (k !== 'jwt') target.searchParams.set(k, v); + }); + target.searchParams.set('jwt', route.jwt); + + // Strip Authorization: metro-api authenticates via the JWT query param. + const headers = new Headers((init?.headers as Record | Headers | undefined) ?? {}); + headers.delete('authorization'); + headers.delete('Authorization'); + + return { + url: target.toString(), + init: { ...init, headers }, + }; +} + +/** + * Try to extract a BrowserRoute from a decoded JSON body that looks like a + * Browser (or a list of them). Returns all extracted routes. + */ +export function extractRoutesFromBody(body: unknown): Array<{ id: string; route: BrowserRoute }> { + const out: Array<{ id: string; route: BrowserRoute }> = []; + const visit = (v: unknown) => { + if (!v || typeof v !== 'object') return; + const obj = v as Record; + + const sessionId = typeof obj['session_id'] === 'string' ? (obj['session_id'] as string) : undefined; + const baseURL = typeof obj['base_url'] === 'string' ? (obj['base_url'] as string) : undefined; + const cdpWsUrl = typeof obj['cdp_ws_url'] === 'string' ? (obj['cdp_ws_url'] as string) : undefined; + if (sessionId && baseURL && cdpWsUrl) { + try { + const jwt = new URL(cdpWsUrl).searchParams.get('jwt'); + if (jwt) out.push({ id: sessionId, route: { baseURL, jwt } }); + } catch { + // Ignore malformed URLs; we'll just not cache this entry. + } + } + + // Common list-response shape: { items: [...] }. + if (Array.isArray(obj['items'])) for (const item of obj['items']) visit(item); + // Defensive: walk direct array contents too. + if (Array.isArray(v)) for (const item of v as unknown[]) visit(item); + }; + visit(body); + return out; +} + +async function sniffAndPopulateCache(response: Response, cache: BrowserRouteCache): Promise { + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) return response; + + // Clone so the caller still gets an untouched body. + const clone = response.clone(); + try { + const body = await clone.json(); + for (const { id, route } of extractRoutesFromBody(body)) { + cache.set(id, route); + } + } catch { + // Not valid JSON or already consumed; ignore. + } + return response; +} + +/** + * Returns a `fetch`-compatible function that transparently routes + * browser subresource calls to metro-direct when possible. + */ +export function createRoutingFetch(opts: RoutingFetchOptions): Fetch { + const { apiBaseURL, inner, cache, logger } = opts; + const parsedApiBase = new URL(apiBaseURL); + + const fn: Fetch = async (input, init) => { + const urlString = typeof input === 'string' ? input : (input as URL).toString(); + let url: URL; + try { + url = new URL(urlString); + } catch { + return inner(input, init); + } + + const match = matchBrowserSubresource(url, parsedApiBase); + if (match.kind === 'skip') { + const resp = await inner(input, init); + return sniffAndPopulateCache(resp, cache); + } + + const route = cache.get(match.id); + if (!route) { + // TODO(demo): lazy-fill via GET /browsers/{id} with single-flight. + // For now just fall through to the public API; the response handler + // below will populate the cache from the Browser response when the + // endpoint happens to return one, or from subsequent create/retrieve + // calls. + logger?.debug?.('[browser-routing] cache miss, falling through', match.id); + const resp = await inner(input, init); + return sniffAndPopulateCache(resp, cache); + } + + const rewritten = rewriteRequest(url, route, match.subresource, match.rest, init); + logger?.debug?.('[browser-routing] routing metro-direct', { + id: match.id, + subresource: match.subresource, + target: rewritten.url, + }); + + const resp = await inner(rewritten.url, rewritten.init); + if (resp.status === 401 || resp.status === 403 || resp.status === 404) { + logger?.warn?.( + '[browser-routing] metro-direct rejected, evicting and retrying via public API', + { id: match.id, status: resp.status }, + ); + cache.evict(match.id); + const fallback = await inner(input, init); + return sniffAndPopulateCache(fallback, cache); + } + return sniffAndPopulateCache(resp, cache); + }; + return fn; +} diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts new file mode 100644 index 00000000..b2294352 --- /dev/null +++ b/tests/lib/browser-routing.test.ts @@ -0,0 +1,251 @@ +import Kernel from '@onkernel/sdk'; +import { + BrowserRouteCache, + extractRoutesFromBody, + METRO_DIRECT_SUBRESOURCES, + createRoutingFetch, +} from '@onkernel/sdk/lib/browser-routing'; + +const API_BASE = 'https://api.example.test'; +const METRO_BASE = 'https://proxy.yul-upbeat-herschel.onkernel.com:8443/browser/kernel'; +const SESSION_ID = 'sess-abc'; +const JWT = 'jwt-xyz'; + +// A Browser-shaped response body that matches the real openapi schema. +function browserResponse(overrides: Partial> = {}) { + return { + session_id: overrides['session_id'] ?? SESSION_ID, + base_url: overrides['base_url'] ?? METRO_BASE, + cdp_ws_url: + overrides['cdp_ws_url'] ?? + `wss://proxy.yul-upbeat-herschel.onkernel.com:8443/browser/kernel/cdp?jwt=${JWT}`, + webdriver_ws_url: 'wss://example/webdriver?jwt=' + JWT, + created_at: '2026-04-21T00:00:00Z', + headless: true, + stealth: false, + timeout_seconds: 600, + }; +} + +// Helper: make a fake fetch that records every call and returns scripted responses. +function makeFakeFetch( + scripts: Array<(url: string, init?: RequestInit) => Response | Promise>, +) { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + let i = 0; + const fetch = async (input: any, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL).toString(); + calls.push({ url, init }); + const script = scripts[i++] ?? scripts[scripts.length - 1]!; + return script(url, init); + }; + return { fetch: fetch as unknown as typeof globalThis.fetch, calls }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('extractRoutesFromBody', () => { + test('extracts route from a single Browser response', () => { + const routes = extractRoutesFromBody(browserResponse()); + expect(routes).toEqual([{ id: SESSION_ID, route: { baseURL: METRO_BASE, jwt: JWT } }]); + }); + + test('extracts routes from a list-response shape', () => { + const routes = extractRoutesFromBody({ + items: [browserResponse(), browserResponse({ session_id: 'sess-2' })], + }); + expect(routes.map((r) => r.id)).toEqual([SESSION_ID, 'sess-2']); + }); + + test('ignores objects that are not Browser-shaped', () => { + expect(extractRoutesFromBody({ foo: 'bar' })).toEqual([]); + expect(extractRoutesFromBody(null)).toEqual([]); + }); + + test('skips Browser entries whose cdp_ws_url has no jwt', () => { + const routes = extractRoutesFromBody( + browserResponse({ cdp_ws_url: 'wss://example/cdp' }), + ); + expect(routes).toEqual([]); + }); +}); + +describe('createRoutingFetch (unit)', () => { + test('rewrites allowlisted subresource calls when cache is warm', async () => { + const cache = new BrowserRouteCache(); + cache.set(SESSION_ID, { baseURL: METRO_BASE, jwt: JWT }); + + const upstream = makeFakeFetch([() => jsonResponse({ ok: true })]); + const routing = createRoutingFetch({ + apiBaseURL: API_BASE, + inner: upstream.fetch as any, + cache, + }); + + const res = await routing(`${API_BASE}/browsers/${SESSION_ID}/process/exec`, { + method: 'POST', + headers: { Authorization: 'Bearer sk-my-api-key', 'content-type': 'application/json' }, + body: JSON.stringify({ command: 'ls', args: [] }), + }); + + expect(res.status).toBe(200); + expect(upstream.calls).toHaveLength(1); + const call = upstream.calls[0]!; + const url = new URL(call.url); + expect(url.origin + url.pathname).toBe(`${METRO_BASE}/process/exec`); + expect(url.searchParams.get('jwt')).toBe(JWT); + + const headers = new Headers(call.init!.headers as any); + expect(headers.has('authorization')).toBe(false); + expect(headers.get('content-type')).toBe('application/json'); + }); + + test('falls through to public API on cache miss', async () => { + const cache = new BrowserRouteCache(); + const upstream = makeFakeFetch([() => jsonResponse({ ok: true })]); + const routing = createRoutingFetch({ + apiBaseURL: API_BASE, + inner: upstream.fetch as any, + cache, + }); + + await routing(`${API_BASE}/browsers/unknown-id/fs/read_file`, { method: 'POST' }); + + expect(upstream.calls).toHaveLength(1); + expect(upstream.calls[0]!.url).toBe(`${API_BASE}/browsers/unknown-id/fs/read_file`); + }); + + test('non-allowlisted subresource is never rewritten even with a warm cache', async () => { + const cache = new BrowserRouteCache(); + cache.set(SESSION_ID, { baseURL: METRO_BASE, jwt: JWT }); + + // Sanity check — extensions deliberately excluded. + expect(METRO_DIRECT_SUBRESOURCES.has('extensions')).toBe(false); + + const upstream = makeFakeFetch([() => jsonResponse({ items: [] })]); + const routing = createRoutingFetch({ + apiBaseURL: API_BASE, + inner: upstream.fetch as any, + cache, + }); + + await routing(`${API_BASE}/browsers/${SESSION_ID}/extensions`, { method: 'GET' }); + + expect(upstream.calls).toHaveLength(1); + expect(upstream.calls[0]!.url).toBe(`${API_BASE}/browsers/${SESSION_ID}/extensions`); + }); + + test('evicts cache and retries public API when metro returns 401', async () => { + const cache = new BrowserRouteCache(); + cache.set(SESSION_ID, { baseURL: METRO_BASE, jwt: JWT }); + + const upstream = makeFakeFetch([ + () => new Response('expired', { status: 401 }), + () => jsonResponse({ ok: true }), + ]); + const routing = createRoutingFetch({ + apiBaseURL: API_BASE, + inner: upstream.fetch as any, + cache, + }); + + const res = await routing(`${API_BASE}/browsers/${SESSION_ID}/process/exec`, { + method: 'POST', + headers: { Authorization: 'Bearer sk-my-api-key' }, + }); + + expect(res.status).toBe(200); + expect(upstream.calls).toHaveLength(2); + // First call: metro-direct (rewritten). + expect(new URL(upstream.calls[0]!.url).origin).toBe(new URL(METRO_BASE).origin); + // Second call: public API fallback (original URL + Authorization preserved). + expect(upstream.calls[1]!.url).toBe(`${API_BASE}/browsers/${SESSION_ID}/process/exec`); + expect(new Headers(upstream.calls[1]!.init!.headers as any).get('authorization')).toBe( + 'Bearer sk-my-api-key', + ); + // And the failed JWT was evicted. + expect(cache.get(SESSION_ID)).toBeUndefined(); + }); + + test('populates cache from Browser-shaped responses', async () => { + const cache = new BrowserRouteCache(); + const upstream = makeFakeFetch([() => jsonResponse(browserResponse())]); + const routing = createRoutingFetch({ + apiBaseURL: API_BASE, + inner: upstream.fetch as any, + cache, + }); + + const res = await routing(`${API_BASE}/browsers`, { method: 'POST' }); + // Caller still gets a working body. + await expect(res.json()).resolves.toMatchObject({ session_id: SESSION_ID }); + expect(cache.get(SESSION_ID)).toEqual({ baseURL: METRO_BASE, jwt: JWT }); + }); +}); + +describe('Kernel integration (browserRouting enabled)', () => { + test('browsers.process.exec routes to metro after a create populates the cache', async () => { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const fakeFetch = (async (input: any, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL).toString(); + calls.push({ url, init }); + if (url.startsWith(API_BASE) && url.includes('/browsers') && init?.method === 'POST' && !url.includes('/process')) { + return jsonResponse(browserResponse()); + } + return jsonResponse({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }) as unknown as typeof globalThis.fetch; + + const client = new Kernel({ + apiKey: 'sk-my-api-key', + baseURL: API_BASE, + browserRouting: { enabled: true }, + fetch: fakeFetch, + }); + + const browser = await client.browsers.create(); + expect(browser.session_id).toBe(SESSION_ID); + expect(client.browserRouteCache?.get(SESSION_ID)).toEqual({ + baseURL: METRO_BASE, + jwt: JWT, + }); + + // Subresource call should transparently go to metro, strip auth, add ?jwt=. + await client.browsers.process.exec(browser.session_id, { command: 'ls' } as any); + + expect(calls.length).toBeGreaterThanOrEqual(2); + const exec = calls.find((c) => c.url.includes('/process/exec'))!; + const execURL = new URL(exec.url); + expect(execURL.origin + execURL.pathname).toBe(`${METRO_BASE}/process/exec`); + expect(execURL.searchParams.get('jwt')).toBe(JWT); + expect(new Headers(exec.init!.headers as any).has('authorization')).toBe(false); + }); + + test('with browserRouting disabled, subresource calls stay on the public API', async () => { + const calls: Array<{ url: string }> = []; + const fakeFetch = (async (input: any, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL).toString(); + calls.push({ url }); + if (url.startsWith(API_BASE) && url.includes('/browsers') && init?.method === 'POST' && !url.includes('/process')) { + return jsonResponse(browserResponse()); + } + return jsonResponse({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }) as unknown as typeof globalThis.fetch; + + const client = new Kernel({ + apiKey: 'sk-my-api-key', + baseURL: API_BASE, + fetch: fakeFetch, + }); + + const browser = await client.browsers.create(); + await client.browsers.process.exec(browser.session_id, { command: 'ls' } as any); + expect(client.browserRouteCache).toBeUndefined(); + const exec = calls.find((c) => c.url.includes('/process/exec'))!; + expect(new URL(exec.url).origin).toBe(new URL(API_BASE).origin); + }); +}); diff --git a/yarn.lock b/yarn.lock index f6eae3cd..9ab3c791 100644 --- a/yarn.lock +++ b/yarn.lock @@ -881,6 +881,13 @@ dependencies: "@babel/types" "^7.20.7" +"@types/busboy@^1.5.4": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@types/busboy/-/busboy-1.5.4.tgz#0038c31102ca90f2a7f0d8bc27ee5ebf1088e230" + integrity sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw== + dependencies: + "@types/node" "*" + "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" @@ -1263,6 +1270,13 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -3054,6 +3068,11 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"