diff --git a/examples/browser-scoped.ts b/examples/browser-scoped.ts new file mode 100644 index 0000000..33f4c5f --- /dev/null +++ b/examples/browser-scoped.ts @@ -0,0 +1,24 @@ +/** + * Browser-scoped client: call browser VM-routed browser APIs without repeating the + * session id, and run `fetch`-style HTTP through the browser network stack. + * + * Run after `yarn build` so `dist/` matches sources, or import from `src/` via + * ts-node with path aliases. + */ +import Kernel from '@onkernel/sdk'; + +async function main() { + const kernel = new Kernel(); + + const created = await kernel.browsers.create({}); + const browser = kernel.forBrowser(created); + + await browser.computer.clickMouse({ x: 10, y: 10 }); + + const page = await browser.fetch('https://example.com', { method: 'GET' }); + console.log('status', page.status); + + await kernel.browsers.deleteByID(created.session_id); +} + +void main(); diff --git a/package.json b/package.json index 08d4984..8437534 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "prepublishOnly": "echo 'to publish, run yarn build && (cd dist; yarn publish)' && exit 1", "format": "./scripts/format", "prepare": "if ./scripts/utils/check-is-in-git-install.sh; then ./scripts/build && ./scripts/utils/git-swap.sh; fi", + "generate:browser-session": "ts-node -T scripts/generate-browser-session.ts", "tsn": "ts-node -r tsconfig-paths/register", "lint": "./scripts/lint", "fix": "./scripts/format" @@ -45,6 +46,7 @@ "prettier": "^3.0.0", "publint": "^0.2.12", "ts-jest": "^29.1.0", + "ts-morph": "^28.0.0", "ts-node": "^10.5.0", "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", diff --git a/scripts/build b/scripts/build index a008cb0..8d747f2 100755 --- a/scripts/build +++ b/scripts/build @@ -6,6 +6,9 @@ cd "$(dirname "$0")/.." node scripts/utils/check-version.cjs +./node_modules/.bin/ts-node -T scripts/generate-browser-session.ts +./node_modules/.bin/prettier --write src/lib/generated/browser-session-bindings.ts >/dev/null + # Build into dist and will publish the package from there, # so that src/resources/foo.ts becomes /resources/foo.js # This way importing from `"@onkernel/sdk/resources/foo"` works diff --git a/scripts/generate-browser-session.ts b/scripts/generate-browser-session.ts new file mode 100644 index 0000000..4369163 --- /dev/null +++ b/scripts/generate-browser-session.ts @@ -0,0 +1,332 @@ +#!/usr/bin/env -S node + +import fs from 'fs'; +import path from 'path'; +import { + Node, + Project, + SyntaxKind, + type MethodDeclaration, + type ParameterDeclaration, + type PropertyDeclaration, +} from 'ts-morph'; + +type ChildMeta = { + propName: string; + targetClass: string; +}; + +type MethodMeta = { + name: string; + signature: string; + returnType: string; + implArgs: string; +}; + +type ResourceMeta = { + className: string; + filePath: string; + importPath: string; + alias: string; + exportedTypeNames: Set; + children: ChildMeta[]; + methods: MethodMeta[]; +}; + +const repoRoot = path.resolve(__dirname, '..'); +const resourcesDir = path.join(repoRoot, 'src', 'resources', 'browsers'); +const outputFile = path.join(repoRoot, 'src', 'lib', 'generated', 'browser-session-bindings.ts'); + +const project = new Project({ + skipAddingFilesFromTsConfig: true, +}); + +project.addSourceFilesAtPaths(path.join(resourcesDir, '**/*.ts')); + +const sourceFiles = project + .getSourceFiles() + .filter((sf) => !sf.getBaseName().endsWith('.test.ts') && sf.getBaseName() !== 'index.ts'); + +const resourceByClass = new Map(); + +for (const sf of sourceFiles) { + for (const classDecl of sf.getClasses()) { + if (classDecl.getName() == null) continue; + if (!extendsAPIResource(classDecl)) continue; + + const className = classDecl.getNameOrThrow(); + const importPath = toImportPath(path.relative(path.dirname(outputFile), sf.getFilePath())); + const alias = `${className}API`; + const exportedTypeNames = new Set( + sf + .getStatements() + .filter( + (stmt) => + Node.isInterfaceDeclaration(stmt) || + Node.isTypeAliasDeclaration(stmt) || + Node.isEnumDeclaration(stmt), + ) + .map((stmt) => { + if ( + Node.isInterfaceDeclaration(stmt) || + Node.isTypeAliasDeclaration(stmt) || + Node.isEnumDeclaration(stmt) + ) { + return stmt.getName(); + } + return ''; + }) + .filter(Boolean), + ); + + const meta: ResourceMeta = { + className, + filePath: sf.getFilePath(), + importPath, + alias, + exportedTypeNames, + children: extractChildren(classDecl), + methods: [], + }; + resourceByClass.set(className, meta); + } +} + +for (const sf of sourceFiles) { + for (const classDecl of sf.getClasses()) { + const className = classDecl.getName(); + if (!className) continue; + const meta = resourceByClass.get(className); + if (!meta) continue; + + for (const method of classDecl.getMethods()) { + const parsed = parseMethod(meta, method); + if (parsed) meta.methods.push(parsed); + } + } +} + +const browserMeta = resourceByClass.get('Browsers'); +if (!browserMeta) { + throw new Error('Could not find Browsers resource'); +} + +const ordered = orderResources(browserMeta, resourceByClass); +const importLines = ordered + .map((meta) => `import type * as ${meta.alias} from '${meta.importPath}';`) + .join('\n'); + +const interfaces = ordered.map((meta) => emitInterface(meta, resourceByClass)).join('\n\n'); + +const constructorAssignments = browserMeta.children + .map((child) => emitAssignment(child, resourceByClass)) + .join('\n '); + +const rootMethods = browserMeta.methods.map((method) => emitMethod(method, 'browsers')).join('\n\n'); + +const output = `// This file is generated by scripts/generate-browser-session.ts.\n// Do not edit by hand.\n\nimport type { Kernel } from '../../client';\nimport type { APIPromise } from '../../core/api-promise';\nimport type { RequestOptions } from '../../internal/request-options';\nimport type { Stream } from '../../core/streaming';\nimport type * as Shared from '../../resources/shared';\n${importLines}\n\n${interfaces}\n\nexport class GeneratedBrowserSessionBindings {\n protected readonly sessionClient: Kernel;\n readonly sessionId: string;\n\n readonly ${browserMeta.children + .map((child) => `${child.propName}: ${bindingName(child.targetClass)}`) + .join( + '\n readonly ', + )};\n\n constructor(sessionClient: Kernel, sessionId: string) {\n this.sessionClient = sessionClient;\n this.sessionId = sessionId;\n ${constructorAssignments}\n }\n\n protected opt(options?: RequestOptions): RequestOptions | undefined {\n return options;\n }\n\n${indent( + rootMethods, + 2, +)}\n}\n`; + +fs.mkdirSync(path.dirname(outputFile), { recursive: true }); +fs.writeFileSync(outputFile, output); + +function extendsAPIResource(classDecl: import('ts-morph').ClassDeclaration): boolean { + const ext = classDecl.getExtends(); + if (!ext) return false; + const text = ext.getExpression().getText(); + return text === 'APIResource'; +} + +function extractChildren(classDecl: import('ts-morph').ClassDeclaration): ChildMeta[] { + return classDecl + .getProperties() + .filter((prop) => !prop.getName().startsWith('with_')) + .map((prop) => { + const targetClass = childClassName(prop); + if (!targetClass) return null; + return { propName: prop.getName(), targetClass }; + }) + .filter((v): v is ChildMeta => v !== null); +} + +function childClassName(prop: PropertyDeclaration): string | null { + const init = prop.getInitializer(); + if (!init || !Node.isNewExpression(init)) return null; + const expr = init.getExpression().getText(); + const last = expr.split('.').pop() || expr; + return last; +} + +function parseMethod(meta: ResourceMeta, method: MethodDeclaration): MethodMeta | null { + if (!isPublicMethod(method)) return null; + const pathText = getPathTemplate(method); + if (!pathText) return null; + if (meta.className === 'Browsers' && !pathText.includes('/browsers/${id}/')) return null; + + const params = method.getParameters(); + const idParam = params[0]?.getName() === 'id' ? params[0] : undefined; + const paramsIdName = detectParamsIdParam(method); + if (!idParam && !paramsIdName) return null; + + const publicParams = params + .filter((param) => param !== idParam) + .map((param) => formatParam(meta, param, paramsIdName, true)) + .join(', '); + + const implArgs = params + .map((param) => { + const name = param.getName(); + if (param === idParam) return 'this.sessionId'; + if (paramsIdName && name === paramsIdName) return `{ ...${name}, id: this.sessionId }`; + if (name === 'options') return 'this.opt(options)'; + return name; + }) + .join(', '); + + return { + name: method.getName(), + signature: publicParams, + returnType: rewriteType(meta, method.getReturnTypeNodeOrThrow().getText()), + implArgs, + }; +} + +function isPublicMethod(method: MethodDeclaration): boolean { + const name = method.getName(); + if (name.startsWith('_')) return false; + if (method.isStatic()) return false; + return true; +} + +function getPathTemplate(method: MethodDeclaration): string | null { + const tag = method + .getDescendantsOfKind(SyntaxKind.TaggedTemplateExpression) + .find((node) => node.getTag().getText() === 'path'); + return tag?.getTemplate().getText() ?? null; +} + +function detectParamsIdParam(method: MethodDeclaration): string | null { + const body = method.getBodyText() ?? ''; + const match = body.match(/const\s+\{\s*id(?:\s*,\s*\.\.\.\w+)?\s*\}\s*=\s*(\w+)/); + return match?.[1] ?? null; +} + +function formatParam( + meta: ResourceMeta, + param: ParameterDeclaration, + paramsIdName: string | null, + includeInitializer: boolean, +): string { + const name = param.getName(); + let typeText = param.getTypeNodeOrThrow().getText(); + typeText = rewriteType(meta, typeText); + if (paramsIdName && name === paramsIdName) { + typeText = `Omit<${typeText}, 'id'>`; + } + const initializer = includeInitializer ? param.getInitializer()?.getText() : undefined; + const question = param.hasQuestionToken() ? '?' : ''; + return `${name}${question}: ${typeText}${initializer ? ` = ${initializer}` : ''}`; +} + +function rewriteType(meta: ResourceMeta, text: string): string { + let out = text; + const typeNames = Array.from(meta.exportedTypeNames).sort((a, b) => b.length - a.length); + for (const name of typeNames) { + out = out.replace(new RegExp(`\\b${name}\\b`, 'g'), `${meta.alias}.${name}`); + } + return out; +} + +function orderResources(root: ResourceMeta, all: Map): ResourceMeta[] { + const out: ResourceMeta[] = []; + const seen = new Set(); + const visit = (meta: ResourceMeta) => { + if (seen.has(meta.className)) return; + seen.add(meta.className); + for (const child of meta.children) { + const childMeta = all.get(child.targetClass); + if (childMeta) visit(childMeta); + } + out.push(meta); + }; + visit(root); + return out.filter((meta) => meta.className !== 'Browsers').concat(root); +} + +function emitInterface(meta: ResourceMeta, all: Map): string { + const lines: string[] = []; + for (const method of meta.methods) { + const noInitSignature = method.signature.replace(/\s*=\s*[^,)+]+/g, ''); + lines.push(` ${method.name}(${noInitSignature}): ${method.returnType};`); + } + for (const child of meta.children) { + if (all.has(child.targetClass)) { + lines.push(` ${child.propName}: ${bindingName(child.targetClass)};`); + } + } + return `export interface ${bindingName(meta.className)} {\n${lines.join('\n')}\n}`; +} + +function bindingName(className: string): string { + return `BrowserSession${className}Bindings`; +} + +function emitAssignment(child: ChildMeta, all: Map): string { + const meta = all.get(child.targetClass)!; + const methodLines = meta.methods.map((method) => { + return `${method.name}: (${method.signature}) => this.sessionClient.browsers.${resourceCallPath( + meta.filePath, + )}.${method.name}(${method.implArgs}),`; + }); + const childLines = meta.children.map((nested) => emitNestedObject(nested, all)); + return `this.${child.propName} = {\n ${[...methodLines, ...childLines].join('\n ')}\n };`; +} + +function emitNestedObject(child: ChildMeta, all: Map): string { + const meta = all.get(child.targetClass)!; + const methodLines = meta.methods.map((method) => { + return `${method.name}: (${method.signature}) => this.sessionClient.browsers.${resourceCallPath( + meta.filePath, + )}.${method.name}(${method.implArgs}),`; + }); + const nestedLines = meta.children.map((nested) => emitNestedObject(nested, all)); + return `${child.propName}: {\n ${[...methodLines, ...nestedLines].join('\n ')}\n },`; +} + +function resourceCallPath(filePath: string): string { + const rel = path.relative(resourcesDir, filePath).replace(/\\/g, '/').replace(/\.ts$/, ''); + const segments = rel.split('/'); + if (segments[segments.length - 1] === 'fs') { + return 'fs'; + } + if (segments[0] === 'fs') { + return ['fs', ...segments.slice(1)].join('.'); + } + if (segments[0] === 'browsers') { + return segments.slice(1).join('.'); + } + return segments.join('.'); +} + +function emitMethod(method: MethodMeta, resourcePrefix: string): string { + return `${method.name}(${method.signature}): ${method.returnType} {\n return this.sessionClient.${resourcePrefix}.${method.name}(${method.implArgs});\n }`; +} + +function toImportPath(relPath: string): string { + const normalized = relPath.replace(/\\/g, '/').replace(/\.ts$/, ''); + return normalized.startsWith('.') ? normalized : `./${normalized}`; +} + +function indent(value: string, depth: number): string { + const prefix = ' '.repeat(depth); + return value + .split('\n') + .map((line) => (line.length ? `${prefix}${line}` : line)) + .join('\n'); +} diff --git a/scripts/lint b/scripts/lint index 3ffb78a..caaa979 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,6 +4,13 @@ set -e cd "$(dirname "$0")/.." +echo "==> Regenerating browser session bindings" +./node_modules/.bin/ts-node -T scripts/generate-browser-session.ts +./node_modules/.bin/prettier --write src/lib/generated/browser-session-bindings.ts >/dev/null + +echo "==> Verifying generated browser session bindings are committed" +git diff --exit-code -- src/lib/generated/browser-session-bindings.ts + echo "==> Running eslint" ./node_modules/.bin/eslint . diff --git a/src/client.ts b/src/client.ts index dc9e5ce..8be4b37 100644 --- a/src/client.ts +++ b/src/client.ts @@ -67,6 +67,7 @@ import { Deployments, } from './resources/deployments'; import { KernelApp } from './core/app-framework'; +import { KernelBrowserSession, type KernelBrowserInput } from './lib/kernel-browser-session'; import { ExtensionDownloadFromChromeStoreParams, ExtensionListResponse, @@ -879,6 +880,15 @@ export class Kernel { return new KernelApp(name); } + /** + * Returns a browser-scoped client: subresource calls omit the session id and + * are routed through {@link BrowserCreateResponse.base_url} (browser session + * HTTP base URL for the browser VM edge). Requires base_url on the browser object. + */ + public forBrowser(browser: KernelBrowserInput): KernelBrowserSession { + return new KernelBrowserSession(this, browser); + } + static Kernel = this; static DEFAULT_TIMEOUT = 60000; // 1 minute diff --git a/src/index.ts b/src/index.ts index 72d9bc0..eaafa63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,12 @@ 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 { + KernelBrowserSession, + type BrowserFetchInit, + type KernelBrowserInput, +} from './lib/kernel-browser-session'; +export { type KernelBrowserLike, type ResolvedBrowserTransport } from './lib/browser-transport'; export { PagePromise } from './core/pagination'; export { KernelError, diff --git a/src/lib/browser-transport.ts b/src/lib/browser-transport.ts new file mode 100644 index 0000000..d355038 --- /dev/null +++ b/src/lib/browser-transport.ts @@ -0,0 +1,62 @@ +import type { RequestOptions } from '../internal/request-options'; + +/** + * Resolved HTTP routing for a browser session. When {@link ResolvedBrowserTransport.defaultBaseURL} + * is set, requests use that browser session base URL plus a per-request jwt query param. + * A future client-wide browser-id → base_url cache can plug in by supplying an alternate + * resolver before constructing {@link KernelBrowserSession}. + */ +export type ResolvedBrowserTransport = { + sessionId: string; + defaultBaseURL?: string | undefined; + jwt?: string | undefined; +}; + +export type KernelBrowserLike = { + session_id: string; + base_url?: string | null | undefined; + cdp_ws_url?: string | null | undefined; + /** When set, overrides jwt parsed from cdp_ws_url */ + jwt?: string | null | undefined; +}; + +export function parseJwtFromCdpWsUrl(cdpWsUrl: string | null | undefined): string | undefined { + if (!cdpWsUrl) { + return undefined; + } + try { + const u = new URL(cdpWsUrl); + const jwt = u.searchParams.get('jwt'); + return jwt ?? undefined; + } catch { + return undefined; + } +} + +export function resolveBrowserTransport(browser: KernelBrowserLike): ResolvedBrowserTransport { + const sessionId = browser.session_id; + const rawBase = browser.base_url?.trim(); + const defaultBaseURL = rawBase && rawBase.length > 0 ? rawBase : undefined; + const jwt = + (typeof browser.jwt === 'string' && browser.jwt.length > 0 ? browser.jwt : undefined) ?? + parseJwtFromCdpWsUrl(browser.cdp_ws_url ?? undefined); + return { sessionId, defaultBaseURL, jwt }; +} + +export function mergeBrowserScopedRequestOptions( + transport: ResolvedBrowserTransport, + options?: RequestOptions, +): RequestOptions | undefined { + if (!transport.defaultBaseURL) { + return options; + } + const next: RequestOptions = { ...options, defaultBaseURL: transport.defaultBaseURL }; + if (transport.jwt) { + const prev = + options?.query && typeof options.query === 'object' && !Array.isArray(options.query) ? + (options.query as Record) + : {}; + next.query = { ...prev, jwt: transport.jwt }; + } + return next; +} diff --git a/src/lib/generated/browser-session-bindings.ts b/src/lib/generated/browser-session-bindings.ts new file mode 100644 index 0000000..4190c10 --- /dev/null +++ b/src/lib/generated/browser-session-bindings.ts @@ -0,0 +1,326 @@ +// This file is generated by scripts/generate-browser-session.ts. +// Do not edit by hand. + +import type { Kernel } from '../../client'; +import type { APIPromise } from '../../core/api-promise'; +import type { RequestOptions } from '../../internal/request-options'; +import type { Stream } from '../../core/streaming'; +import type * as Shared from '../../resources/shared'; +import type * as ReplaysAPI from '../../resources/browsers/replays'; +import type * as WatchAPI from '../../resources/browsers/fs/watch'; +import type * as FsAPI from '../../resources/browsers/fs/fs'; +import type * as ProcessAPI from '../../resources/browsers/process'; +import type * as LogsAPI from '../../resources/browsers/logs'; +import type * as ComputerAPI from '../../resources/browsers/computer'; +import type * as PlaywrightAPI from '../../resources/browsers/playwright'; +import type * as BrowsersAPI from '../../resources/browsers/browsers'; + +export interface BrowserSessionReplaysBindings { + list(options?: RequestOptions): APIPromise; + download( + replayID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + start( + body: ReplaysAPI.ReplayStartParams | null | undefined, + options?: RequestOptions, + ): APIPromise; + stop( + replayID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; +} + +export interface BrowserSessionWatchBindings { + events( + watchID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise>; + start(body: WatchAPI.WatchStartParams, options?: RequestOptions): APIPromise; + stop( + watchID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; +} + +export interface BrowserSessionFsBindings { + createDirectory(body: FsAPI.FCreateDirectoryParams, options?: RequestOptions): APIPromise; + deleteDirectory(body: FsAPI.FDeleteDirectoryParams, options?: RequestOptions): APIPromise; + deleteFile(body: FsAPI.FDeleteFileParams, options?: RequestOptions): APIPromise; + downloadDirZip(query: FsAPI.FDownloadDirZipParams, options?: RequestOptions): APIPromise; + fileInfo(query: FsAPI.FFileInfoParams, options?: RequestOptions): APIPromise; + listFiles(query: FsAPI.FListFilesParams, options?: RequestOptions): APIPromise; + move(body: FsAPI.FMoveParams, options?: RequestOptions): APIPromise; + readFile(query: FsAPI.FReadFileParams, options?: RequestOptions): APIPromise; + setFilePermissions(body: FsAPI.FSetFilePermissionsParams, options?: RequestOptions): APIPromise; + upload(body: FsAPI.FUploadParams, options?: RequestOptions): APIPromise; + uploadZip(body: FsAPI.FUploadZipParams, options?: RequestOptions): APIPromise; + writeFile( + contents: string | ArrayBuffer | ArrayBufferView | Blob | DataView, + params: FsAPI.FWriteFileParams, + options?: RequestOptions, + ): APIPromise; + watch: BrowserSessionWatchBindings; +} + +export interface BrowserSessionProcessBindings { + exec( + body: ProcessAPI.ProcessExecParams, + options?: RequestOptions, + ): APIPromise; + kill( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + resize( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + spawn( + body: ProcessAPI.ProcessSpawnParams, + options?: RequestOptions, + ): APIPromise; + status( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + stdin( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise; + stdoutStream( + processID: string, + params: Omit, + options?: RequestOptions, + ): APIPromise>; +} + +export interface BrowserSessionLogsBindings { + stream(query: LogsAPI.LogStreamParams, options?: RequestOptions): APIPromise>; +} + +export interface BrowserSessionComputerBindings { + batch(body: ComputerAPI.ComputerBatchParams, options?: RequestOptions): APIPromise; + captureScreenshot( + body: ComputerAPI.ComputerCaptureScreenshotParams | null | undefined, + options?: RequestOptions, + ): APIPromise; + clickMouse(body: ComputerAPI.ComputerClickMouseParams, options?: RequestOptions): APIPromise; + dragMouse(body: ComputerAPI.ComputerDragMouseParams, options?: RequestOptions): APIPromise; + getMousePosition(options?: RequestOptions): APIPromise; + moveMouse(body: ComputerAPI.ComputerMoveMouseParams, options?: RequestOptions): APIPromise; + pressKey(body: ComputerAPI.ComputerPressKeyParams, options?: RequestOptions): APIPromise; + readClipboard(options?: RequestOptions): APIPromise; + scroll(body: ComputerAPI.ComputerScrollParams, options?: RequestOptions): APIPromise; + setCursorVisibility( + body: ComputerAPI.ComputerSetCursorVisibilityParams, + options?: RequestOptions, + ): APIPromise; + typeText(body: ComputerAPI.ComputerTypeTextParams, options?: RequestOptions): APIPromise; + writeClipboard(body: ComputerAPI.ComputerWriteClipboardParams, options?: RequestOptions): APIPromise; +} + +export interface BrowserSessionPlaywrightBindings { + execute( + body: PlaywrightAPI.PlaywrightExecuteParams, + options?: RequestOptions, + ): APIPromise; +} + +export interface BrowserSessionBrowsersBindings { + loadExtensions(body: BrowsersAPI.BrowserLoadExtensionsParams, options?: RequestOptions): APIPromise; + replays: BrowserSessionReplaysBindings; + fs: BrowserSessionFsBindings; + process: BrowserSessionProcessBindings; + logs: BrowserSessionLogsBindings; + computer: BrowserSessionComputerBindings; + playwright: BrowserSessionPlaywrightBindings; +} + +export class GeneratedBrowserSessionBindings { + protected readonly sessionClient: Kernel; + readonly sessionId: string; + + readonly replays: BrowserSessionReplaysBindings; + readonly fs: BrowserSessionFsBindings; + readonly process: BrowserSessionProcessBindings; + readonly logs: BrowserSessionLogsBindings; + readonly computer: BrowserSessionComputerBindings; + readonly playwright: BrowserSessionPlaywrightBindings; + + constructor(sessionClient: Kernel, sessionId: string) { + this.sessionClient = sessionClient; + this.sessionId = sessionId; + this.replays = { + list: (options?: RequestOptions) => + this.sessionClient.browsers.replays.list(this.sessionId, this.opt(options)), + download: ( + replayID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.replays.download( + replayID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + start: (body: ReplaysAPI.ReplayStartParams | null | undefined = {}, options?: RequestOptions) => + this.sessionClient.browsers.replays.start(this.sessionId, body, this.opt(options)), + stop: (replayID: string, params: Omit, options?: RequestOptions) => + this.sessionClient.browsers.replays.stop( + replayID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + }; + this.fs = { + createDirectory: (body: FsAPI.FCreateDirectoryParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.createDirectory(this.sessionId, body, this.opt(options)), + deleteDirectory: (body: FsAPI.FDeleteDirectoryParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.deleteDirectory(this.sessionId, body, this.opt(options)), + deleteFile: (body: FsAPI.FDeleteFileParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.deleteFile(this.sessionId, body, this.opt(options)), + downloadDirZip: (query: FsAPI.FDownloadDirZipParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.downloadDirZip(this.sessionId, query, this.opt(options)), + fileInfo: (query: FsAPI.FFileInfoParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.fileInfo(this.sessionId, query, this.opt(options)), + listFiles: (query: FsAPI.FListFilesParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.listFiles(this.sessionId, query, this.opt(options)), + move: (body: FsAPI.FMoveParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.move(this.sessionId, body, this.opt(options)), + readFile: (query: FsAPI.FReadFileParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.readFile(this.sessionId, query, this.opt(options)), + setFilePermissions: (body: FsAPI.FSetFilePermissionsParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.setFilePermissions(this.sessionId, body, this.opt(options)), + upload: (body: FsAPI.FUploadParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.upload(this.sessionId, body, this.opt(options)), + uploadZip: (body: FsAPI.FUploadZipParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.uploadZip(this.sessionId, body, this.opt(options)), + writeFile: ( + contents: string | ArrayBuffer | ArrayBufferView | Blob | DataView, + params: FsAPI.FWriteFileParams, + options?: RequestOptions, + ) => this.sessionClient.browsers.fs.writeFile(this.sessionId, contents, params, this.opt(options)), + watch: { + events: (watchID: string, params: Omit, options?: RequestOptions) => + this.sessionClient.browsers.fs.watch.events( + watchID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + start: (body: WatchAPI.WatchStartParams, options?: RequestOptions) => + this.sessionClient.browsers.fs.watch.start(this.sessionId, body, this.opt(options)), + stop: (watchID: string, params: Omit, options?: RequestOptions) => + this.sessionClient.browsers.fs.watch.stop( + watchID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + }, + }; + this.process = { + exec: (body: ProcessAPI.ProcessExecParams, options?: RequestOptions) => + this.sessionClient.browsers.process.exec(this.sessionId, body, this.opt(options)), + kill: (processID: string, params: Omit, options?: RequestOptions) => + this.sessionClient.browsers.process.kill( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + resize: ( + processID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.process.resize( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + spawn: (body: ProcessAPI.ProcessSpawnParams, options?: RequestOptions) => + this.sessionClient.browsers.process.spawn(this.sessionId, body, this.opt(options)), + status: ( + processID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.process.status( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + stdin: ( + processID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.process.stdin( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + stdoutStream: ( + processID: string, + params: Omit, + options?: RequestOptions, + ) => + this.sessionClient.browsers.process.stdoutStream( + processID, + { ...params, id: this.sessionId }, + this.opt(options), + ), + }; + this.logs = { + stream: (query: LogsAPI.LogStreamParams, options?: RequestOptions) => + this.sessionClient.browsers.logs.stream(this.sessionId, query, this.opt(options)), + }; + this.computer = { + batch: (body: ComputerAPI.ComputerBatchParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.batch(this.sessionId, body, this.opt(options)), + captureScreenshot: ( + body: ComputerAPI.ComputerCaptureScreenshotParams | null | undefined = {}, + options?: RequestOptions, + ) => this.sessionClient.browsers.computer.captureScreenshot(this.sessionId, body, this.opt(options)), + clickMouse: (body: ComputerAPI.ComputerClickMouseParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.clickMouse(this.sessionId, body, this.opt(options)), + dragMouse: (body: ComputerAPI.ComputerDragMouseParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.dragMouse(this.sessionId, body, this.opt(options)), + getMousePosition: (options?: RequestOptions) => + this.sessionClient.browsers.computer.getMousePosition(this.sessionId, this.opt(options)), + moveMouse: (body: ComputerAPI.ComputerMoveMouseParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.moveMouse(this.sessionId, body, this.opt(options)), + pressKey: (body: ComputerAPI.ComputerPressKeyParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.pressKey(this.sessionId, body, this.opt(options)), + readClipboard: (options?: RequestOptions) => + this.sessionClient.browsers.computer.readClipboard(this.sessionId, this.opt(options)), + scroll: (body: ComputerAPI.ComputerScrollParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.scroll(this.sessionId, body, this.opt(options)), + setCursorVisibility: (body: ComputerAPI.ComputerSetCursorVisibilityParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.setCursorVisibility(this.sessionId, body, this.opt(options)), + typeText: (body: ComputerAPI.ComputerTypeTextParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.typeText(this.sessionId, body, this.opt(options)), + writeClipboard: (body: ComputerAPI.ComputerWriteClipboardParams, options?: RequestOptions) => + this.sessionClient.browsers.computer.writeClipboard(this.sessionId, body, this.opt(options)), + }; + this.playwright = { + execute: (body: PlaywrightAPI.PlaywrightExecuteParams, options?: RequestOptions) => + this.sessionClient.browsers.playwright.execute(this.sessionId, body, this.opt(options)), + }; + } + + protected opt(options?: RequestOptions): RequestOptions | undefined { + return options; + } + + loadExtensions(body: BrowsersAPI.BrowserLoadExtensionsParams, options?: RequestOptions): APIPromise { + return this.sessionClient.browsers.loadExtensions(this.sessionId, body, this.opt(options)); + } +} diff --git a/src/lib/kernel-browser-session.ts b/src/lib/kernel-browser-session.ts new file mode 100644 index 0000000..21f47a8 --- /dev/null +++ b/src/lib/kernel-browser-session.ts @@ -0,0 +1,252 @@ +import type { HeadersInit, RequestInfo, RequestInit } from '../internal/builtin-types'; +import { Kernel } from '../client'; +import { KernelError } from '../core/error'; +import type { FinalRequestOptions, RequestOptions } from '../internal/request-options'; +import type { + BrowserCreateResponse, + BrowserListResponse, + BrowserRetrieveResponse, +} from '../resources/browsers/browsers'; +import { buildHeaders } from '../internal/headers'; +import { GeneratedBrowserSessionBindings } from './generated/browser-session-bindings'; +import { + resolveBrowserTransport, + type KernelBrowserLike, + type ResolvedBrowserTransport, +} from './browser-transport'; + +export type KernelBrowserInput = + | KernelBrowserLike + | BrowserCreateResponse + | BrowserRetrieveResponse + | BrowserListResponse; + +export interface BrowserFetchInit extends RequestInit { + /** Passed to the upstream /curl/raw handler as timeout_ms when set. */ + timeout_ms?: number; +} + +/** + * Browser-scoped API view: subresources omit the browser session id and are routed + * through {@link BrowserCreateResponse.base_url} (browser session HTTP base URL for + * the browser VM edge) with jwt query authentication. + */ +export class KernelBrowserSession extends GeneratedBrowserSessionBindings { + protected override readonly sessionClient: Kernel; + private readonly transport: ResolvedBrowserTransport; + + constructor(kernel: Kernel, browser: KernelBrowserInput) { + const transport = resolveBrowserTransport(browser); + const sessionId = transport.sessionId; + const baseURL = transport.defaultBaseURL; + if (!baseURL) { + throw new KernelError( + 'kernel.forBrowser requires browser.base_url from the Kernel API. Create or retrieve the browser and pass a response that includes base_url before using the browser session client.', + ); + } + + const sessionClient = createBrowserSessionKernel(kernel, { + ...transport, + defaultBaseURL: baseURL, + }); + super(sessionClient, sessionId); + this.sessionClient = sessionClient; + this.transport = transport; + } + + /** + * Issue an HTTP request through the browser VM network stack (Chrome), returning + * the upstream response as a standard Fetch {@link Response}. Implemented via + * the browser session base URL and POST /curl/raw (internal). + */ + async fetch(input: RequestInfo | URL, init?: BrowserFetchInit): Promise { + if (!this.transport.jwt) { + throw new KernelError( + 'browser.fetch requires a browser session jwt (parsed from cdp_ws_url, or pass jwt on the browser object).', + ); + } + + const { url: targetUrl, method, headers, body, signal, duplex, timeout_ms } = splitFetchArgs(input, init); + assertHttpTargetUrl(targetUrl); + + const query: Record = { + url: targetUrl, + jwt: this.transport.jwt, + }; + if (timeout_ms !== undefined) { + query['timeout_ms'] = timeout_ms; + } + + const accept = headers.get('accept'); + const headerPairs = headersToRequestOptionsHeaders(headers); + + const methodLower = method.toLowerCase(); + const allowed = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); + if (!allowed.has(methodLower)) { + throw new KernelError(`browser.fetch unsupported HTTP method: ${method}`); + } + + return this.sessionClient + .request({ + method: methodLower, + path: '/curl/raw', + query, + body: body as RequestOptions['body'], + headers: buildHeaders([accept ? { Accept: accept } : { Accept: '*/*' }, headerPairs]), + signal: signal ?? null, + ...(duplex ? { fetchOptions: { duplex } as RequestOptions['fetchOptions'] } : {}), + __binaryResponse: true, + } as any) + .asResponse(); + } +} + +function createBrowserSessionKernel( + parent: Kernel, + transport: ResolvedBrowserTransport & { defaultBaseURL: string }, +): Kernel { + const defaultQuery = + transport.jwt ? + { + ...(((parent as any)._options?.defaultQuery as Record | undefined) ?? {}), + jwt: transport.jwt, + } + : (parent as any)._options?.defaultQuery ?? undefined; + + const sessionClient = parent.withOptions({ + baseURL: transport.defaultBaseURL, + defaultQuery: defaultQuery as Record | undefined, + }) as Kernel; + + const originalPrepareOptions = ( + (sessionClient as any).prepareOptions as ((options: FinalRequestOptions) => Promise) | undefined + )?.bind(sessionClient); + + (sessionClient as any).authHeaders = async () => undefined; + (sessionClient as any).prepareOptions = async (options: FinalRequestOptions) => { + if (originalPrepareOptions) { + await originalPrepareOptions(options); + } + const prefix = `/browsers/${transport.sessionId}/`; + if (options.path.startsWith(prefix)) { + const rest = options.path.slice(prefix.length); + options.path = rest.startsWith('/') ? rest : `/${rest}`; + } + }; + + return sessionClient; +} + +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 merged = new Headers(input.headers); + if (init?.headers) { + const extra = new Headers(init.headers as HeadersInit); + extra.forEach((value, key) => { + merged.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: merged, + }; + const mergedBody = init?.body ?? input.body; + if (mergedBody !== undefined && mergedBody !== null) { + out.body = mergedBody; + } + const mergedSignal = init?.signal ?? input.signal; + if (mergedSignal !== undefined) { + out.signal = mergedSignal; + } + if (init?.duplex !== undefined) { + out.duplex = init.duplex; + } + if (timeoutFromInit !== undefined) { + out.timeout_ms = timeoutFromInit; + } + return out; + } + + const url = input instanceof URL ? input.href : String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + const headers = new Headers(init?.headers as HeadersInit | undefined); + const out: { + url: string; + method: string; + headers: Headers; + body?: RequestInit['body']; + signal?: AbortSignal | null; + duplex?: RequestInit['duplex']; + timeout_ms?: number; + } = { url, method, 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 assertHttpTargetUrl(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) => { + const lower = key.toLowerCase(); + if ( + lower === 'accept' || + lower === 'content-length' || + lower === 'connection' || + lower === 'keep-alive' || + lower === 'proxy-authenticate' || + lower === 'proxy-authorization' || + lower === 'te' || + lower === 'trailers' || + lower === 'transfer-encoding' || + lower === 'upgrade' + ) { + return; + } + out[key] = value; + }); + return out; +} diff --git a/src/resources/browser-pools.ts b/src/resources/browser-pools.ts index f587ded..76315b2 100644 --- a/src/resources/browser-pools.ts +++ b/src/resources/browser-pools.ts @@ -306,7 +306,7 @@ export interface BrowserPoolAcquireResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; diff --git a/src/resources/browsers/browsers.ts b/src/resources/browsers/browsers.ts index c4a910e..9fbcf3e 100644 --- a/src/resources/browsers/browsers.ts +++ b/src/resources/browsers/browsers.ts @@ -319,7 +319,7 @@ export interface BrowserCreateResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; @@ -425,7 +425,7 @@ export interface BrowserRetrieveResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; @@ -531,7 +531,7 @@ export interface BrowserUpdateResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; @@ -637,7 +637,7 @@ export interface BrowserListResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; diff --git a/src/resources/invocations.ts b/src/resources/invocations.ts index 430751b..011637c 100644 --- a/src/resources/invocations.ts +++ b/src/resources/invocations.ts @@ -454,7 +454,7 @@ export namespace InvocationListBrowsersResponse { webdriver_ws_url: string; /** - * Metro-API HTTP base URL for this browser session. + * HTTP base URL for routing browser subresource requests to this session's browser VM. */ base_url?: string; diff --git a/tests/lib/browser-transport.test.ts b/tests/lib/browser-transport.test.ts new file mode 100644 index 0000000..2eba521 --- /dev/null +++ b/tests/lib/browser-transport.test.ts @@ -0,0 +1,48 @@ +import { + mergeBrowserScopedRequestOptions, + parseJwtFromCdpWsUrl, + resolveBrowserTransport, +} from '../../src/lib/browser-transport'; + +describe('browser transport', () => { + test('parseJwtFromCdpWsUrl reads jwt query param', () => { + const jwt = parseJwtFromCdpWsUrl('wss://browser-session.test/browser/cdp?jwt=abc%2B123&x=1'); + expect(jwt).toBe('abc+123'); + }); + + test('resolveBrowserTransport prefers explicit jwt', () => { + const t = resolveBrowserTransport({ + session_id: 'sess', + base_url: 'https://vm.browser-session.test/browser/kernel', + cdp_ws_url: 'wss://x/cdp?jwt=fromcdp', + jwt: 'explicit', + }); + expect(t.sessionId).toBe('sess'); + expect(t.defaultBaseURL).toBe('https://vm.browser-session.test/browser/kernel'); + expect(t.jwt).toBe('explicit'); + }); + + test('resolveBrowserTransport falls back to cdp_ws_url jwt', () => { + const t = resolveBrowserTransport({ + session_id: 'sess', + base_url: 'https://vm.browser-session.test/browser/kernel', + cdp_ws_url: 'wss://x/cdp?jwt=fromcdp', + }); + expect(t.jwt).toBe('fromcdp'); + }); + + test('mergeBrowserScopedRequestOptions injects jwt into query', () => { + const merged = mergeBrowserScopedRequestOptions( + { sessionId: 's', defaultBaseURL: 'https://m/k', jwt: 'j' }, + { query: { a: '1' } }, + ); + expect(merged?.defaultBaseURL).toBe('https://m/k'); + expect(merged?.query).toEqual({ a: '1', jwt: 'j' }); + }); + + test('mergeBrowserScopedRequestOptions is noop without browser session base URL', () => { + const opts = { query: { a: '1' } }; + const merged = mergeBrowserScopedRequestOptions({ sessionId: 's' }, opts); + expect(merged).toBe(opts); + }); +}); diff --git a/tests/lib/kernel-browser-session.test.ts b/tests/lib/kernel-browser-session.test.ts new file mode 100644 index 0000000..5291ab0 --- /dev/null +++ b/tests/lib/kernel-browser-session.test.ts @@ -0,0 +1,82 @@ +import Kernel from '@onkernel/sdk'; +import { KernelBrowserSession } from '../../src/lib/kernel-browser-session'; + +describe('KernelBrowserSession', () => { + test('throws when base_url is missing', () => { + const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); + expect( + () => + new KernelBrowserSession(kernel, { + session_id: 'abc', + cdp_ws_url: 'wss://x/browser/cdp?jwt=j', + }), + ).toThrow(/base_url/); + }); + + test('throws when jwt cannot be resolved', async () => { + const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); + const browser = new KernelBrowserSession(kernel, { + session_id: 'abc', + base_url: 'https://vm.browser-session.test/browser/kernel', + cdp_ws_url: 'wss://x/browser/cdp', + }); + await expect(browser.fetch('https://example.com')).rejects.toThrow(/jwt/); + }); + + test('issues /curl/raw against browser session base URL with jwt query', async () => { + const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; + const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); + (kernel as any).fetch = async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, init }); + return new Response('ok', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }); + }; + + const browser = new KernelBrowserSession(kernel, { + session_id: 'abc', + base_url: 'https://vm.browser-session.test/browser/kernel', + cdp_ws_url: 'wss://x/browser/cdp?jwt=tok', + }); + + const res = await browser.fetch('https://example.com/hello', { + method: 'GET', + headers: { 'X-Test': '1' }, + }); + expect(res.status).toBe(200); + expect(fetchCalls.length).toBe(1); + const call = fetchCalls[0]!; + expect(call.url).toContain('https://vm.browser-session.test/browser/kernel/curl/raw?'); + expect(call.url).toContain('url=https%3A%2F%2Fexample.com%2Fhello'); + expect(call.url).toContain('jwt=tok'); + expect((call.init?.headers as Headers).get('authorization')).toBeNull(); + }); + + test('rewrites browser subresource paths through browser session base URL', async () => { + const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; + const kernel = new Kernel({ apiKey: 'k', baseURL: 'https://api.example/' }); + (kernel as any).fetch = async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, init }); + return new Response('', { + status: 200, + headers: { 'content-type': '*/*' }, + }); + }; + + const browser = new KernelBrowserSession(kernel, { + session_id: 'abc', + base_url: 'https://vm.browser-session.test/browser/kernel', + cdp_ws_url: 'wss://x/browser/cdp?jwt=tok', + }); + + await browser.computer.clickMouse({ x: 1, y: 2 }); + + expect(fetchCalls.length).toBe(1); + const call = fetchCalls[0]!; + expect(call.url).toContain('https://vm.browser-session.test/browser/kernel/computer/click_mouse?'); + expect(call.url).toContain('jwt=tok'); + expect(call.url).not.toContain('/browsers/abc/'); + expect((call.init?.headers as Headers).get('authorization')).toBeNull(); + }); +}); diff --git a/yarn.lock b/yarn.lock index f6eae3c..8480389 100644 --- a/yarn.lock +++ b/yarn.lock @@ -828,6 +828,15 @@ dependencies: "@swc/counter" "^0.1.3" +"@ts-morph/common@~0.29.0": + version "0.29.0" + resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.29.0.tgz#bb1ed737f309c8270bb2e92207066343c1302ae2" + integrity sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg== + dependencies: + minimatch "^10.0.1" + path-browserify "^1.0.1" + tinyglobby "^0.2.14" + "@tsconfig/node10@^1.0.7": version "1.0.8" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" @@ -881,6 +890,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 +1279,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" @@ -1365,6 +1388,11 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== +code-block-writer@^13.0.3: + version "13.0.3" + resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-13.0.3.tgz#90f8a84763a5012da7af61319dd638655ae90b5b" + integrity sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg== + collect-v8-coverage@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" @@ -1714,6 +1742,11 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + fflate@^0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" @@ -2587,7 +2620,7 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1, minimatch@^9.0.4, minimatch@^9.0.5: +minimatch@^10.0.1, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1, minimatch@^9.0.4, minimatch@^9.0.5: version "9.0.9" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== @@ -2794,6 +2827,11 @@ parse5@^6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -2824,6 +2862,11 @@ picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + pirates@^4.0.4: version "4.0.6" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" @@ -3054,6 +3097,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" @@ -3174,6 +3222,14 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +tinyglobby@^0.2.14: + version "0.2.16" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -3205,6 +3261,14 @@ ts-jest@^29.1.0: semver "^7.5.3" yargs-parser "^21.0.1" +ts-morph@^28.0.0: + version "28.0.0" + resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-28.0.0.tgz#cd3ebdf89742a32b06ea7327df6445364ee26631" + integrity sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g== + dependencies: + "@ts-morph/common" "~0.29.0" + code-block-writer "^13.0.3" + ts-node@^10.5.0: version "10.7.0" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.7.0.tgz#35d503d0fab3e2baa672a0e94f4b40653c2463f5"