diff --git a/.release-please-manifest.json b/.release-please-manifest.json index febc17a4..75d439ac 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.50.0" + ".": "0.51.0" } diff --git a/.stats.yml b/.stats.yml index bed10345..d58220c1 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 112 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-686a9addd4f9356ca26ff3ff04e1a11466d77a412859829075566394922b715d.yml -openapi_spec_hash: 7a9e9c2023400d44bcbfb87b7ec07708 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-a674e3c4c0063942621d1b4e7f67b72f7e240c12dd88564fe16627618ba33dd6.yml +openapi_spec_hash: 8b97c87f0dafe5fc5e5a7365f3687755 config_hash: 08d55086449943a8fec212b870061a3f diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dd9b84..1c289b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## 0.51.0 (2026-04-25) + +Full Changelog: [v0.50.0...v0.51.0](https://github.com/kernel/kernel-node-sdk/compare/v0.50.0...v0.51.0) + +### Features + +* add browser-scoped session client ([94228be](https://github.com/kernel/kernel-node-sdk/commit/94228be699cc42b42caccba4b179875fc6bfcab3)) +* Expire stuck IN_PROGRESS managed auth sessions via background worker ([8c5e75d](https://github.com/kernel/kernel-node-sdk/commit/8c5e75d924c6a77703afa260cc2f5046e2617303)) +* Expose browser_session_id on managed auth connection ([410b647](https://github.com/kernel/kernel-node-sdk/commit/410b647f81d64dc632ccc6f8fb09bbb2a364b49d)) +* generate browser-scoped session bindings ([e730af8](https://github.com/kernel/kernel-node-sdk/commit/e730af816f72663041170b4602c009ae5e86ba9c)) +* restore node browser fetch helper ([b09434e](https://github.com/kernel/kernel-node-sdk/commit/b09434ec0642ec166a749beccc985510ed3a4723)) + + +### Bug Fixes + +* clean up node browser routing lint drift ([7a56ab6](https://github.com/kernel/kernel-node-sdk/commit/7a56ab6c6c08697f1a6c4ddc104a9c6abab5bd98)) +* drop node browser routing branch churn ([2f16386](https://github.com/kernel/kernel-node-sdk/commit/2f1638684e9e687f26c24bd7e2d54bb335810ef8)) +* enforce browser base_url routing ([d835f69](https://github.com/kernel/kernel-node-sdk/commit/d835f6925b8940bbf58703ddbb03858117f0e070)) +* evict deleted browser routes ([2d0056e](https://github.com/kernel/kernel-node-sdk/commit/2d0056e0dabe2e4716d50d053a12e2b8e95048a0)) +* handle browser pool route cache updates ([81e47ca](https://github.com/kernel/kernel-node-sdk/commit/81e47caffea08415750b6a3480b59a4f698c4187)) +* keep browser routing helpers out of generated code ([a76f7ae](https://github.com/kernel/kernel-node-sdk/commit/a76f7ae7e8d040835cd38eb026fad626dfa718db)) +* limit browser route cache sniffing ([0e0e88f](https://github.com/kernel/kernel-node-sdk/commit/0e0e88fd88d3f1c11a9b3dc5f10c3c379a10bf96)) +* preserve browser routing fetch options ([9b24280](https://github.com/kernel/kernel-node-sdk/commit/9b242808521a9960ce48174af2e3e2ccb30a7a84)) +* require base_url for browser-scoped routing ([ae9a739](https://github.com/kernel/kernel-node-sdk/commit/ae9a739d0b1026b3ebf71d6f7d11ce52070cf42e)) +* restore generated types formatting ([a7ff9bc](https://github.com/kernel/kernel-node-sdk/commit/a7ff9bcae419cefbebaccaf4e07545504ec39356)) +* simplify node browser routing helpers ([fdd3adf](https://github.com/kernel/kernel-node-sdk/commit/fdd3adf1cd0bbd669a6aa2cd053359256735d9a7)) + + +### Chores + +* **internal:** more robust bootstrap script ([4ba0696](https://github.com/kernel/kernel-node-sdk/commit/4ba0696f2b38548f3dcb6763cddd3490b090f658)) + + +### Documentation + +* restore raw http example in browser routing demo ([0d9ddce](https://github.com/kernel/kernel-node-sdk/commit/0d9ddced05e9b030efd055ccf9fc75080ec6dcec)) + + +### Refactors + +* move node browser routing rollout to env ([00c91ef](https://github.com/kernel/kernel-node-sdk/commit/00c91ef1de5a18604fe6e66a57227a2cc794cdba)) +* rename browser routing subresources config ([7030d96](https://github.com/kernel/kernel-node-sdk/commit/7030d969217535fd7e29215ef1e45e8625e6c3e4)) +* replace browser-scoped client with browser routing cache ([2f12277](https://github.com/kernel/kernel-node-sdk/commit/2f1227728f83205144169be48de7b4f145f920d7)) +* simplify browser routing cache ([2082705](https://github.com/kernel/kernel-node-sdk/commit/20827054115bd07042359acc6a167c72ab8a581f)) + ## 0.50.0 (2026-04-20) Full Changelog: [v0.49.0...v0.50.0](https://github.com/kernel/kernel-node-sdk/compare/v0.49.0...v0.50.0) diff --git a/examples/browser-routing.ts b/examples/browser-routing.ts new file mode 100644 index 00000000..0e4da960 --- /dev/null +++ b/examples/browser-routing.ts @@ -0,0 +1,13 @@ +import Kernel from '@onkernel/sdk'; + +async function main() { + const kernel = new Kernel(); + + const browser = await kernel.browsers.create({}); + const response = await kernel.browsers.fetch(browser.session_id, 'https://example.com', { method: 'GET' }); + console.log('status', response.status); + + await kernel.browsers.deleteByID(browser.session_id); +} + +void main(); diff --git a/package-lock.json b/package-lock.json index a012986d..fcd5846e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@onkernel/sdk", - "version": "0.50.0", + "version": "0.51.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@onkernel/sdk", - "version": "0.50.0", + "version": "0.51.0", "license": "Apache-2.0", "devDependencies": { "@arethetypeswrong/cli": "^0.17.0", diff --git a/package.json b/package.json index 7eabcc83..73f08cc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onkernel/sdk", - "version": "0.50.0", + "version": "0.51.0", "description": "The official TypeScript library for the Kernel API", "author": "Kernel <>", "types": "dist/index.d.ts", diff --git a/scripts/bootstrap b/scripts/bootstrap index a8b69ff3..2e315f53 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response diff --git a/src/client.ts b/src/client.ts index 7a00e5e5..636679ae 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,6 +20,11 @@ import * as Uploads from './core/uploads'; import * as API from './resources/index'; import { APIPromise } from './core/api-promise'; import { AppListParams, AppListResponse, AppListResponsesOffsetPagination, Apps } from './resources/apps'; +import { + BrowserRouteCache, + browserRoutingSubresourcesFromEnv, + createRoutingFetch, +} from './lib/browser-routing'; import { BrowserPool, BrowserPoolAcquireParams, @@ -247,9 +252,11 @@ export class Kernel { fetchOptions: MergedRequestInit | undefined; private fetch: Fetch; + private rawFetch: Fetch; #encoder: Opts.RequestEncoder; protected idempotencyHeader?: string; private _options: ClientOptions; + public browserRouteCache: BrowserRouteCache; /** * API Client for interfacing with the Kernel API. @@ -312,7 +319,13 @@ export class Kernel { defaultLogLevel; this.fetchOptions = options.fetchOptions; this.maxRetries = options.maxRetries ?? 2; - this.fetch = options.fetch ?? Shims.getDefaultFetch(); + this.rawFetch = options.fetch ?? Shims.getDefaultFetch(); + this.browserRouteCache = new BrowserRouteCache(); + this.fetch = createRoutingFetch(this.rawFetch, { + apiBaseURL: this.baseURL, + subresources: browserRoutingSubresourcesFromEnv(), + cache: this.browserRouteCache, + }); this.#encoder = Opts.FallbackEncoder; this._options = options; @@ -332,11 +345,17 @@ export class Kernel { timeout: this.timeout, logger: this.logger, logLevel: this.logLevel, - fetch: this.fetch, + fetch: this.rawFetch, fetchOptions: this.fetchOptions, apiKey: this.apiKey, ...options, }); + client.browserRouteCache = this.browserRouteCache; + client.fetch = createRoutingFetch(client.rawFetch, { + apiBaseURL: client.baseURL, + subresources: browserRoutingSubresourcesFromEnv(), + cache: client.browserRouteCache, + }); return client; } diff --git a/src/index.ts b/src/index.ts index 72d9bc02..81aa3351 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ export { Kernel as default } from './client'; export { type Uploadable, toFile } from './core/uploads'; export { APIPromise } from './core/api-promise'; export { Kernel, type ClientOptions } from './client'; +export { type BrowserFetchInit } from './lib/browser-fetch'; +export { BrowserRouteCache, type BrowserRoute } from './lib/browser-routing'; export { PagePromise } from './core/pagination'; export { KernelError, diff --git a/src/lib/browser-fetch.ts b/src/lib/browser-fetch.ts new file mode 100644 index 00000000..63cbc18e --- /dev/null +++ b/src/lib/browser-fetch.ts @@ -0,0 +1,180 @@ +import type { RequestInfo, RequestInit } from '../internal/builtin-types'; +import { KernelError } from '../core/error'; +import { buildHeaders } from '../internal/headers'; +import type { FinalRequestOptions, RequestOptions } from '../internal/request-options'; +import type { HTTPMethod } from '../internal/types'; +import { joinURL } from './join-url'; +import type { Kernel } from '../client'; + +export interface BrowserFetchInit extends RequestInit { + timeout_ms?: number; +} + +export async function browserFetch( + client: Kernel, + sessionId: string, + input: RequestInfo | URL, + init?: BrowserFetchInit, +): Promise { + const route = client.browserRouteCache.get(sessionId); + if (!route) { + throw new KernelError( + `browser route cache does not contain session ${sessionId}; create, retrieve, or list the browser before calling browser.fetch`, + ); + } + + const { url: targetURL, method, headers, body, signal, duplex, timeout_ms } = splitFetchArgs(input, init); + assertHTTPURL(targetURL); + + const query: Record = { url: targetURL, jwt: route.jwt }; + if (timeout_ms !== undefined) { + query['timeout_ms'] = timeout_ms; + } + + const accept = headers.get('accept'); + const requestOptions: FinalRequestOptions = { + method: normalizeMethod(method), + path: joinURL(route.baseURL, '/curl/raw'), + query, + body: body as RequestOptions['body'], + headers: buildHeaders([ + { Authorization: null }, + accept ? { Accept: accept } : { Accept: '*/*' }, + headersToRequestOptionsHeaders(headers), + ]), + signal: signal ?? null, + __binaryResponse: true, + }; + if (duplex) { + requestOptions.fetchOptions = { duplex } as NonNullable; + } + + return client.request(requestOptions).asResponse(); +} + +function normalizeMethod(method: string): HTTPMethod { + const methodLower = method.toLowerCase(); + const allowed = new Set(['get', 'post', 'put', 'patch', 'delete']); + if (!allowed.has(methodLower)) { + throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`); + } + return methodLower as HTTPMethod; +} + +function splitFetchArgs( + input: RequestInfo | URL, + init?: BrowserFetchInit, +): { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; +} { + const timeoutFromInit = init && 'timeout_ms' in init ? init['timeout_ms'] : undefined; + + if (input instanceof Request) { + const headers = new Headers(input.headers); + if (init?.headers) { + const extra = new Headers(init.headers); + extra.forEach((value, key) => { + headers.set(key, value); + }); + } + + const out: { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; + } = { + url: input.url, + method: (init?.method ?? input.method)?.toUpperCase() || 'GET', + headers, + }; + const body = init?.body ?? input.body; + if (body !== undefined && body !== null) { + out.body = body; + } + const signal = init?.signal ?? input.signal; + if (signal !== undefined) { + out.signal = signal; + } + if (init?.duplex !== undefined) { + out.duplex = init.duplex; + } + if (timeoutFromInit !== undefined) { + out.timeout_ms = timeoutFromInit; + } + return out; + } + + const out: { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; + } = { + url: input instanceof URL ? input.href : String(input), + method: (init?.method ?? 'GET').toUpperCase(), + headers: new Headers(init?.headers), + }; + if (init?.body !== undefined) { + out.body = init.body; + } + if (init?.signal !== undefined) { + out.signal = init.signal; + } + if (init?.duplex !== undefined) { + out.duplex = init.duplex; + } + if (timeoutFromInit !== undefined) { + out.timeout_ms = timeoutFromInit; + } + return out; +} + +function assertHTTPURL(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new KernelError(`browser.fetch target must be an absolute URL; received: ${url}`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new KernelError(`browser.fetch only supports http(s) URLs; received: ${parsed.protocol}`); + } +} + +function headersToRequestOptionsHeaders(headers: Headers): Record { + const out: Record = {}; + + headers.forEach((value, key) => { + switch (key.toLowerCase()) { + case 'accept': + case 'content-length': + case 'connection': + case 'keep-alive': + case 'proxy-authenticate': + case 'proxy-authorization': + case 'te': + case 'trailers': + case 'transfer-encoding': + case 'upgrade': + return; + default: + out[key] = value; + } + }); + + return out; +} diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts new file mode 100644 index 00000000..6580da6c --- /dev/null +++ b/src/lib/browser-routing.ts @@ -0,0 +1,329 @@ +import type { Fetch, RequestInfo, RequestInit } from '../internal/builtin-types'; +import { joinURL } from './join-url'; + +export type BrowserRoute = { + sessionId: string; + baseURL: string; + jwt: string; +}; + +export class BrowserRouteCache { + private entries = new Map(); + + get(sessionId: string): BrowserRoute | undefined { + return this.entries.get(sessionId); + } + + set(route: BrowserRoute): void { + this.entries.set(route.sessionId, route); + } + + delete(sessionId: string): void { + this.entries.delete(sessionId); + } + + clear(): void { + this.entries.clear(); + } +} + +const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; +const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl']; +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\/?$/; + +export function browserRoutingSubresourcesFromEnv(): string[] { + const raw = readBrowserRoutingSubresourcesEnv(); + if (raw === undefined) { + return [...DEFAULT_BROWSER_ROUTING_SUBRESOURCES]; + } + + if (raw.trim() === '') { + return []; + } + + return raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + +export function createRoutingFetch( + innerFetch: Fetch, + { + apiBaseURL, + subresources, + cache, + }: { + apiBaseURL: string; + subresources: Iterable; + cache: BrowserRouteCache; + }, +): Fetch { + const allowed = new Set([...subresources].map((value) => value.trim()).filter(Boolean)); + const apiOrigin = new URL(apiBaseURL).origin; + + return async (input, init) => { + const request = new Request(input, init); + const shouldSniff = shouldSniffAndPopulateCache(request, apiOrigin); + const response = await routeRequest(innerFetch, { input, init, request }, apiOrigin, allowed, cache); + if (shouldSniff) { + await sniffAndPopulateCache(response, cache); + } + await maybeEvictBrowserRoute(request, response, apiOrigin, cache); + return response; + }; +} + +function shouldSniffAndPopulateCache(request: Request, apiOrigin: string): boolean { + const url = new URL(request.url); + return ( + url.origin === apiOrigin && + (BROWSER_ROUTE_CACHEABLE_PATH.test(url.pathname) || BROWSER_POOL_ACQUIRE_PATH.test(url.pathname)) + ); +} + +async function maybeEvictBrowserRoute( + request: Request, + response: Response, + apiOrigin: string, + cache: BrowserRouteCache, +): Promise { + if (!response.ok) { + return; + } + + const url = new URL(request.url); + if (url.origin !== apiOrigin) { + return; + } + + const sessionId = + deletedSessionIdFromPath(request, url.pathname) ?? + (await releasedSessionIdFromRequest(request, url.pathname)); + if (sessionId) { + cache.delete(sessionId); + } +} + +function deletedSessionIdFromPath(request: Request, pathname: string): string | undefined { + if (request.method.toUpperCase() !== 'DELETE') { + return undefined; + } + + const match = pathname.match(BROWSER_DELETE_BY_ID_PATH); + if (!match) { + return undefined; + } + + const sessionId = decodeURIComponent(match[1] ?? ''); + return sessionId || undefined; +} + +async function releasedSessionIdFromRequest(request: Request, pathname: string): Promise { + if (request.method.toUpperCase() !== 'POST' || !BROWSER_POOL_RELEASE_PATH.test(pathname)) { + return undefined; + } + + try { + const body = await request.clone().json(); + if (!body || typeof body !== 'object') { + return undefined; + } + + const sessionId = (body as Record)['session_id']; + return typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : undefined; + } catch { + return undefined; + } +} + +function browserRouteFromValue(value: unknown): BrowserRoute | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const record = value as Record; + const sessionId = typeof record['session_id'] === 'string' ? record['session_id'].trim() : ''; + const baseURL = typeof record['base_url'] === 'string' ? record['base_url'].trim() : ''; + if (!sessionId || !baseURL) { + return undefined; + } + + const explicitJWT = typeof record['jwt'] === 'string' ? record['jwt'].trim() : ''; + const cdpWsURL = typeof record['cdp_ws_url'] === 'string' ? record['cdp_ws_url'] : undefined; + const jwt = explicitJWT || parseJwtFromCdpWsUrl(cdpWsURL) || ''; + if (!jwt) { + return undefined; + } + return { + sessionId, + baseURL, + jwt, + }; +} + +async function sniffAndPopulateCache(response: Response, cache: BrowserRouteCache): Promise { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + if (!contentType.includes('application/json')) { + return; + } + + try { + populateCache(await response.clone().json(), cache); + } catch { + // Ignore malformed JSON in routing cache population. + } +} + +function populateCache(value: unknown, cache: BrowserRouteCache): void { + const route = browserRouteFromValue(value); + if (route) { + cache.set(route); + } + + if (Array.isArray(value)) { + for (const item of value) { + populateCache(item, cache); + } + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + for (const child of Object.values(value as Record)) { + if (typeof child === 'object' && child !== null) { + populateCache(child, cache); + } + } +} + +async function routeRequest( + innerFetch: Fetch, + { + input, + init, + request, + }: { + input: RequestInfo; + init: RequestInit | undefined; + request: Request; + }, + apiOrigin: string, + allowed: ReadonlySet, + cache: BrowserRouteCache, +): Promise { + const url = new URL(request.url); + if (url.origin !== apiOrigin) { + return innerFetch(input, init); + } + + const match = url.pathname.match(/^\/(?:v\d+\/)?browsers\/([^/]+)\/([^/]+)(\/.*)?$/); + if (!match) { + return innerFetch(input, init); + } + + const sessionId = decodeURIComponent(match[1] ?? ''); + const subresource = match[2] ?? ''; + if (!sessionId || !allowed.has(subresource)) { + return innerFetch(input, init); + } + const route = cache.get(sessionId); + if (route === undefined) { + return innerFetch(input, init); + } + + const target = new URL(joinURL(route.baseURL, `/${subresource}${match[3] ?? ''}`)); + url.searchParams.forEach((value, key) => { + if (key !== 'jwt') { + target.searchParams.append(key, value); + } + }); + if (!target.searchParams.get('jwt')) { + target.searchParams.set('jwt', route.jwt); + } + + const headers = new Headers(request.headers); + headers.delete('authorization'); + return innerFetch(target.toString(), buildRoutedInit(request, init, headers)); +} + +function buildRoutedInit( + request: Request, + originalInit: RequestInit | undefined, + headers: Headers, +): RequestInit { + const method = request.method.toUpperCase(); + const routedInit = { + ...((originalInit ?? {}) as Record), + method, + headers, + redirect: request.redirect, + signal: request.signal, + } as RequestInit & Record; + + delete routedInit['body']; + delete routedInit['duplex']; + + if (method !== 'GET' && method !== 'HEAD') { + const body = requestBodyForFetch(request, originalInit); + if (body !== undefined) { + routedInit.body = body; + } + if (originalInit?.duplex !== undefined) { + routedInit.duplex = originalInit.duplex; + } else if (requiresHalfDuplex(body)) { + routedInit.duplex = 'half'; + } + } + + return routedInit; +} + +function requestBodyForFetch( + request: Request, + originalInit: RequestInit | undefined, +): RequestInit['body'] | undefined { + if (originalInit?.body !== undefined && originalInit.body !== null) { + return originalInit.body; + } + + return request.body ?? undefined; +} + +function requiresHalfDuplex(body: RequestInit['body'] | undefined): boolean { + return ( + ((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream) || + (typeof body === 'object' && body !== null && Symbol.asyncIterator in body) + ); +} + +function parseJwtFromCdpWsUrl(cdpWsUrl: string | undefined): string | undefined { + if (!cdpWsUrl) { + return undefined; + } + + try { + return new URL(cdpWsUrl).searchParams.get('jwt') ?? undefined; + } catch { + return undefined; + } +} + +function readBrowserRoutingSubresourcesEnv(): string | undefined { + if (typeof (globalThis as any).process !== 'undefined') { + const value = (globalThis as any).process.env?.[BROWSER_ROUTING_SUBRESOURCES_ENV]; + return typeof value === 'string' ? value : undefined; + } + + if (typeof (globalThis as any).Deno !== 'undefined') { + const value = (globalThis as any).Deno.env?.get?.(BROWSER_ROUTING_SUBRESOURCES_ENV); + return typeof value === 'string' ? value : undefined; + } + + return undefined; +} diff --git a/src/lib/join-url.ts b/src/lib/join-url.ts new file mode 100644 index 00000000..7dd54dd6 --- /dev/null +++ b/src/lib/join-url.ts @@ -0,0 +1,3 @@ +export function joinURL(baseURL: string, path: string): string { + return `${baseURL.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; +} diff --git a/src/resources/auth/connections.ts b/src/resources/auth/connections.ts index 852cd6b4..256800dd 100644 --- a/src/resources/auth/connections.ts +++ b/src/resources/auth/connections.ts @@ -244,6 +244,13 @@ export interface ManagedAuth { */ allowed_domains?: Array; + /** + * ID of the underlying browser session driving the current flow (present when flow + * in progress). Use this to inspect or terminate the browser session via the + * `/browsers` API. + */ + browser_session_id?: string | null; + /** * Whether automatic re-authentication is possible (has credential, selectors, and * login_url) @@ -286,7 +293,10 @@ export interface ManagedAuth { external_action_message?: string | null; /** - * When the current flow expires (null when no flow in progress) + * When the current flow expires (null when no flow in progress). A flow past this + * timestamp is no longer valid and its `flow_status` will be `EXPIRED`. Clients + * may start a new login to supersede a stale `IN_PROGRESS` flow past this + * timestamp. */ flow_expires_at?: string | null; @@ -326,10 +336,21 @@ export interface ManagedAuth { hosted_url?: string | null; /** - * When the profile was last successfully authenticated + * @deprecated Deprecated alias for `last_auth_check_at`. Despite the name, this is + * the last health-check timestamp, not the last successful authentication. Use + * `last_auth_check_at` instead. */ last_auth_at?: string; + /** + * When the most recent auth health check ran for this connection, regardless of + * outcome. Updated on every health check and does not by itself indicate that the + * profile is currently authenticated - use `status` for that. May be newer than + * `flow_expires_at` when a flow is still in progress because health checks + * continue to run in parallel. + */ + last_auth_check_at?: string; + /** * Browser live view URL for debugging (present when flow in progress) */ diff --git a/src/resources/browsers/browsers.ts b/src/resources/browsers/browsers.ts index 61873f31..0cad78e9 100644 --- a/src/resources/browsers/browsers.ts +++ b/src/resources/browsers/browsers.ts @@ -1,5 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +import type { RequestInfo } from '../../internal/builtin-types'; import { APIResource } from '../../core/resource'; import * as Shared from '../shared'; import * as ComputerAPI from './computer'; @@ -75,6 +76,7 @@ import { buildHeaders } from '../../internal/headers'; import { RequestOptions } from '../../internal/request-options'; import { multipartFormRequestOptions } from '../../internal/uploads'; import { path } from '../../internal/utils/path'; +import { browserFetch, type BrowserFetchInit } from '../../lib/browser-fetch'; /** * Create and manage browser sessions. @@ -184,6 +186,14 @@ export class Browsers extends APIResource { return this._client.post(path`/browsers/${id}/curl`, { body, ...options }); } + /** + * Issues an HTTP request through the browser VM network stack, routing directly + * to the browser's `base_url` using the shared browser route cache. + */ + fetch(id: string, input: RequestInfo | URL, init?: BrowserFetchInit): Promise { + return browserFetch(this._client, id, input, init); + } + /** * Delete a browser session by ID * diff --git a/src/version.ts b/src/version.ts index 049e29ec..7768f744 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.50.0'; // x-release-please-version +export const VERSION = '0.51.0'; // x-release-please-version diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts new file mode 100644 index 00000000..1ca285cc --- /dev/null +++ b/tests/lib/browser-routing.test.ts @@ -0,0 +1,395 @@ +import Kernel from '@onkernel/sdk'; + +import { + BrowserRouteCache, + browserRoutingSubresourcesFromEnv, + createRoutingFetch, +} from '../../src/lib/browser-routing'; + +describe('browser routing', () => { + const browserRoutingEnv = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; + + const withBrowserRoutingEnv = async (value: string | undefined, fn: () => Promise) => { + const previous = process.env[browserRoutingEnv]; + if (value === undefined) { + delete process.env[browserRoutingEnv]; + } else { + process.env[browserRoutingEnv] = value; + } + try { + await fn(); + } finally { + if (previous === undefined) { + delete process.env[browserRoutingEnv]; + } else { + process.env[browserRoutingEnv] = previous; + } + } + }; + + const normalizeURL = (input: string | URL | Request) => { + if (typeof input === 'string') { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + return input.url; + }; + + test('warms cache from browser responses and routes allowlisted subresources directly to the VM', async () => { + await withBrowserRoutingEnv('process,curl', async () => { + const calls: Array<{ url: string; headers: Headers }> = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input, init?: RequestInit) => { + const url = normalizeURL(input); + const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers); + calls.push({ url, headers }); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.process.exec('sess-1', { command: 'echo', args: ['hi'] }); + + expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + expect(calls).toHaveLength(2); + expect(calls[1]?.url).toBe('http://browser-session.test/browser/kernel/process/exec?jwt=token-abc'); + expect(calls[1]?.headers.get('authorization')).toBeNull(); + }); + }); + + test('does not route non-allowlisted subresources directly to the VM', async () => { + await withBrowserRoutingEnv('computer', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.process.exec('sess-1', { command: 'echo' }); + + expect(calls[1]).toBe('https://api.example/browsers/sess-1/process/exec'); + }); + }); + + test('withOptions reuses the same browser route cache without double-wrapping fetch', async () => { + await withBrowserRoutingEnv('process', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + await kernel.browsers.create(); + + const child = kernel.withOptions({ timeout: 1234 }); + await child.browsers.process.exec('sess-1', { command: 'echo' }); + + expect(child.browserRouteCache).toBe(kernel.browserRouteCache); + expect(calls).toEqual([ + 'https://api.example/browsers', + 'http://browser-session.test/browser/kernel/process/exec?jwt=token-abc', + ]); + }); + }); + + test('skips cache sniffing for non-browser JSON responses', async () => { + let cloneCalled = false; + const wrappedFetch = createRoutingFetch( + async () => { + const response = Response.json({ ok: true }); + const clone = response.clone.bind(response); + Object.defineProperty(response, 'clone', { + value: () => { + cloneCalled = true; + return clone(); + }, + }); + return response; + }, + { + apiBaseURL: 'https://api.example/', + subresources: ['process'], + cache: new BrowserRouteCache(), + }, + ); + + await wrappedFetch('https://api.example/deployments'); + + expect(cloneCalled).toBe(false); + }); + + test('preserves custom fetch options for both API and routed VM requests', async () => { + await withBrowserRoutingEnv('process', async () => { + const dispatcher = Symbol('dispatcher'); + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetchOptions: { dispatcher } as any, + fetch: async (input, init?: RequestInit) => { + const url = normalizeURL(input); + calls.push({ url, init }); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.process.exec('sess-1', { command: 'echo' }); + + expect((calls[0]?.init as any)?.dispatcher).toBe(dispatcher); + expect((calls[1]?.init as any)?.dispatcher).toBe(dispatcher); + }); + }); + + test('ignores browser responses that do not include a usable jwt', async () => { + await withBrowserRoutingEnv('process', async () => { + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browsers.create(); + expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined(); + }); + }); + + test('browser.fetch uses the shared cache and fails clearly on cache miss', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }); + }, + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + await kernel.browsers.fetch('sess-1', 'https://example.com/hello'); + expect(calls[0]).toContain('http://browser-session.test/browser/kernel/curl/raw?'); + + kernel.browserRouteCache.delete('sess-1'); + await expect(kernel.browsers.fetch('sess-1', 'https://example.com/again')).rejects.toThrow(/route cache/); + }); + + test('warms cache from browser pool acquire responses', async () => { + await withBrowserRoutingEnv('process', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + if (url === 'https://api.example/browser_pools/pool-1/acquire') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browserPools.acquire('pool-1', {}); + await kernel.browsers.process.exec('sess-1', { command: 'echo' }); + + expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + expect(calls).toEqual([ + 'https://api.example/browser_pools/pool-1/acquire', + 'http://browser-session.test/browser/kernel/process/exec?jwt=token-abc', + ]); + }); + }); + + test('evicts cached route after successful browser delete by id', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + return new Response(null, { status: 204 }); + }, + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await kernel.browsers.deleteByID('sess-1'); + + expect(calls).toEqual(['https://api.example/browsers/sess-1']); + expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined(); + }); + + test('evicts cached route after successful browser pool release', async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + return new Response(null, { status: 204 }); + }, + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await kernel.browserPools.release('pool-1', { session_id: 'sess-1' }); + + expect(calls).toEqual(['https://api.example/browser_pools/pool-1/release']); + expect(kernel.browserRouteCache.get('sess-1')).toBeUndefined(); + }); + + test('keeps cached route when browser delete by id fails', async () => { + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + maxRetries: 0, + fetch: async () => new Response('boom', { status: 500, headers: { 'content-type': 'text/plain' } }), + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await expect(kernel.browsers.deleteByID('sess-1')).rejects.toThrow(); + expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + }); + + test('keeps cached route when browser pool release fails', async () => { + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + maxRetries: 0, + fetch: async () => new Response('boom', { status: 500, headers: { 'content-type': 'text/plain' } }), + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await expect(kernel.browserPools.release('pool-1', { session_id: 'sess-1' })).rejects.toThrow(); + expect(kernel.browserRouteCache.get('sess-1')).toMatchObject({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + }); + + test('browser.fetch rejects methods outside the SDK HTTPMethod union', async () => { + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async () => new Response(null, { status: 204 }), + }); + + kernel.browserRouteCache.set({ + sessionId: 'sess-1', + baseURL: 'http://browser-session.test/browser/kernel', + jwt: 'token-abc', + }); + + await expect( + kernel.browsers.fetch('sess-1', 'https://example.com/hello', { method: 'HEAD' }), + ).rejects.toThrow(/unsupported HTTP method/i); + await expect( + kernel.browsers.fetch('sess-1', 'https://example.com/hello', { method: 'OPTIONS' }), + ).rejects.toThrow(/unsupported HTTP method/i); + }); + + test('defaults browser routing subresources to curl when env is unset', async () => { + await withBrowserRoutingEnv(undefined, async () => { + expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl']); + }); + }); + + test('disables browser subresource routing when env is set to empty string', async () => { + await withBrowserRoutingEnv('', async () => { + expect(browserRoutingSubresourcesFromEnv()).toEqual([]); + }); + }); +});