diff --git a/fern/generators.yml b/fern/generators.yml index 88defde3..e082c8d3 100644 --- a/fern/generators.yml +++ b/fern/generators.yml @@ -30,6 +30,8 @@ groups: repository: Skyvern-AI/skyvern-typescript config: namespaceExport: Skyvern + extraDependencies: + playwright: '^1.48.0' packageJson: description: "The Skyvern TypeScript library provides convenient access to the Skyvern APIs from TypeScript." publishConfig: diff --git a/scripts/fern_build_ts_sdk.sh b/scripts/fern_build_ts_sdk.sh index f6fca99e..dd6fdd2d 100755 --- a/scripts/fern_build_ts_sdk.sh +++ b/scripts/fern_build_ts_sdk.sh @@ -1,19 +1,35 @@ #!/usr/bin/env bash -CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') +#CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') fern generate --group ts-sdk --log-level debug --version "$CURRENT_VERSION" --preview -(cd fern/.preview/fern-typescript-sdk \ - && npm install \ - && npx tsc --project ./tsconfig.cjs.json \ - && npx tsc --project ./tsconfig.esm.json \ - && node scripts/rename-to-esm-files.js dist/esm) - -rm -fr skyvern-ts/client mkdir -p skyvern-ts/client +mv skyvern-ts/client/src/library skyvern-ts/library +rm -rf skyvern-ts/client +mkdir -p skyvern-ts/client/src/library +mv skyvern-ts/library skyvern-ts/client/src/ cp -rf fern/.preview/fern-typescript-sdk/* skyvern-ts/client/ # Post-processing: Update repository references the monorepo sed -i.bak 's|Skyvern-AI/skyvern-typescript|Skyvern-AI/skyvern|g' skyvern-ts/client/package.json sed -i.bak 's|https://github.com/Skyvern-AI/skyvern-typescript/blob/HEAD/./reference.md|https://www.skyvern.com/docs/api-reference/api-reference|g' skyvern-ts/client/README.md -rm -f skyvern-ts/client/package.json.bak skyvern-ts/client/README.md.bak \ No newline at end of file +rm -f skyvern-ts/client/package.json.bak skyvern-ts/client/README.md.bak + +# Export library classes from main index +cat >> skyvern-ts/client/src/index.ts << 'EOF' +export { Skyvern, SkyvernBrowser, SkyvernBrowserPageAgent, SkyvernBrowserPageAi } from "./library/index.js"; +export type { SkyvernOptions, SkyvernBrowserPage } from "./library/index.js"; +EOF + +# Rename the API namespace to avoid conflict with Skyvern class +sed -i.bak 's/export \* as Skyvern from/export * as SkyvernApi from/g' skyvern-ts/client/src/index.ts +rm -f skyvern-ts/client/src/index.ts.bak + +(cd skyvern-ts/client \ + && rm -rf node_modules package-lock.json \ + && npm install \ + && npx tsc --project ./tsconfig.cjs.json \ + && npx tsc --project ./tsconfig.esm.json \ + && node scripts/rename-to-esm-files.js dist/esm) + +pre-commit run --all-files diff --git a/skyvern-ts/client/package-lock.json b/skyvern-ts/client/package-lock.json index 0fa55b6f..b7f3c57b 100644 --- a/skyvern-ts/client/package-lock.json +++ b/skyvern-ts/client/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "@skyvern/client", "version": "1.0.2", + "dependencies": { + "playwright": "^1.48.0" + }, "devDependencies": { "@biomejs/biome": "2.2.5", "@types/node": "^18.19.70", @@ -1660,9 +1663,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001759", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", - "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", "dev": true, "funding": [ { @@ -2044,10 +2047,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2410,6 +2412,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3073,6 +3105,21 @@ } } }, + "node_modules/vite-node/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vite-node/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -3288,6 +3335,21 @@ } } }, + "node_modules/vitest/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", diff --git a/skyvern-ts/client/package.json b/skyvern-ts/client/package.json index 09050a6f..195e8363 100644 --- a/skyvern-ts/client/package.json +++ b/skyvern-ts/client/package.json @@ -46,6 +46,9 @@ "test:unit": "vitest --project unit", "test:wire": "vitest --project wire" }, + "dependencies": { + "playwright": "^1.48.0" + }, "devDependencies": { "webpack": "^5.97.1", "ts-loader": "^9.5.1", diff --git a/skyvern-ts/client/pnpm-lock.yaml b/skyvern-ts/client/pnpm-lock.yaml index 0be5d564..e89f2f53 100644 --- a/skyvern-ts/client/pnpm-lock.yaml +++ b/skyvern-ts/client/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: excludeLinksFromLockfile: false importers: .: + dependencies: + playwright: + specifier: ^1.48.0 + version: 1.57.0 devDependencies: '@biomejs/biome': specifier: 2.2.5 @@ -555,6 +559,10 @@ packages: fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -648,6 +656,14 @@ packages: picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + engines: {node: '>=18'} + hasBin: true + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + engines: {node: '>=18'} + hasBin: true postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1313,6 +1329,8 @@ snapshots: fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + fsevents@2.3.2: + optional: true fsevents@2.3.3: optional: true get-caller-file@2.0.5: {} @@ -1383,6 +1401,12 @@ snapshots: picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.3: {} + playwright-core@1.57.0: {} + playwright@1.57.0: + dependencies: + playwright-core: 1.57.0 + optionalDependencies: + fsevents: 2.3.2 postcss@8.5.6: dependencies: nanoid: 3.3.11 diff --git a/skyvern-ts/client/src/index.ts b/skyvern-ts/client/src/index.ts index 101bd60e..a923e77d 100644 --- a/skyvern-ts/client/src/index.ts +++ b/skyvern-ts/client/src/index.ts @@ -1,5 +1,7 @@ -export * as Skyvern from "./api/index.js"; +export * as SkyvernApi from "./api/index.js"; export type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js"; export { SkyvernClient } from "./Client.js"; export { SkyvernEnvironment } from "./environments.js"; export { SkyvernError, SkyvernTimeoutError } from "./errors/index.js"; +export { Skyvern, SkyvernBrowser, SkyvernBrowserPageAgent, SkyvernBrowserPageAi } from "./library/index.js"; +export type { SkyvernOptions, SkyvernBrowserPage } from "./library/index.js"; diff --git a/skyvern-ts/client/src/library/Skyvern.ts b/skyvern-ts/client/src/library/Skyvern.ts new file mode 100644 index 00000000..bb313f98 --- /dev/null +++ b/skyvern-ts/client/src/library/Skyvern.ts @@ -0,0 +1,279 @@ + +import { chromium } from "playwright"; +import type * as SkyvernApi from "../api/index.js"; +import type { BaseClientOptions } from "../BaseClient.js"; +import { SkyvernClient } from "../Client.js"; +import { SkyvernEnvironment } from "../environments.js"; +import { SkyvernBrowser } from "./SkyvernBrowser.js"; +import type { GetRunResponse, ProxyLocation } from "../api/index.js"; +import { LOG } from "./logger.js"; +import * as core from "../core/index.js"; + +export interface SkyvernOptions extends BaseClientOptions { + apiKey: string; +} + +export interface RunTaskOptions extends SkyvernApi.RunTaskRequest { + waitForCompletion?: boolean; + timeout?: number; +} + +export interface RunWorkflowOptions extends SkyvernApi.RunWorkflowRequest { + waitForCompletion?: boolean; + timeout?: number; +} + +export interface LoginOptions extends SkyvernApi.LoginRequest { + waitForCompletion?: boolean; + timeout?: number; +} + +export interface DownloadFilesOptions extends SkyvernApi.DownloadFilesRequest { + waitForCompletion?: boolean; + timeout?: number; +} + +export class Skyvern extends SkyvernClient { + private readonly _apiKey: string; + private readonly _environment: SkyvernEnvironment | string; + private readonly _browsers: Set = new Set(); + + constructor(options: SkyvernOptions) { + super({ + ...options, + environment: options.environment ?? SkyvernEnvironment.Cloud, + }); + + this._apiKey = options.apiKey; + this._environment = (options.environment ?? SkyvernEnvironment.Cloud) as SkyvernEnvironment | string; + } + + get environment(): SkyvernEnvironment | string { + return this._environment; + } + + runTask( + request: RunTaskOptions, + requestOptions?: SkyvernClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__runTaskWithCompletion(request, requestOptions)); + } + + private async __runTaskWithCompletion( + request: RunTaskOptions, + requestOptions?: SkyvernClient.RequestOptions, + ): Promise> { + const { waitForCompletion, timeout, ...taskRequest } = request; + + const response = await super.runTask(taskRequest, requestOptions).withRawResponse(); + + if (waitForCompletion) { + const completedRun = await this._waitForRunCompletion( + response.data.run_id, + timeout ?? 1800, + ) as SkyvernApi.TaskRunResponse; + return { data: completedRun, rawResponse: response.rawResponse }; + } + + return response; + } + + runWorkflow( + request: RunWorkflowOptions, + requestOptions?: SkyvernClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__runWorkflowWithCompletion(request, requestOptions)); + } + + private async __runWorkflowWithCompletion( + request: RunWorkflowOptions, + requestOptions?: SkyvernClient.RequestOptions, + ): Promise> { + const { waitForCompletion, timeout, ...workflowRequest } = request; + + const response = await super.runWorkflow(workflowRequest, requestOptions).withRawResponse(); + + if (waitForCompletion) { + const completedRun = await this._waitForRunCompletion( + response.data.run_id, + timeout ?? 1800, + ) as SkyvernApi.WorkflowRunResponse; + return { data: completedRun, rawResponse: response.rawResponse }; + } + + return response; + } + + login( + request: LoginOptions, + requestOptions?: SkyvernClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__loginWithCompletion(request, requestOptions)); + } + + private async __loginWithCompletion( + request: LoginOptions, + requestOptions?: SkyvernClient.RequestOptions, + ): Promise> { + const { waitForCompletion, timeout, ...loginRequest } = request; + + const response = await super.login(loginRequest, requestOptions).withRawResponse(); + + if (waitForCompletion) { + const completedRun = await this._waitForRunCompletion( + response.data.run_id, + timeout ?? 1800, + ) as SkyvernApi.WorkflowRunResponse; + return { data: completedRun, rawResponse: response.rawResponse }; + } + + return response; + } + + downloadFiles( + request: DownloadFilesOptions, + requestOptions?: SkyvernClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__downloadFilesWithCompletion(request, requestOptions)); + } + + private async __downloadFilesWithCompletion( + request: DownloadFilesOptions, + requestOptions?: SkyvernClient.RequestOptions, + ): Promise> { + const { waitForCompletion, timeout, ...downloadFilesRequest } = request; + + const response = await super.downloadFiles(downloadFilesRequest, requestOptions).withRawResponse(); + + if (waitForCompletion) { + const completedRun = await this._waitForRunCompletion( + response.data.run_id, + timeout ?? 1800, + ) as SkyvernApi.WorkflowRunResponse; + return { data: completedRun, rawResponse: response.rawResponse }; + } + + return response; + } + + async launchCloudBrowser(options?: { + timeout?: number; + proxyLocation?: SkyvernApi.ProxyLocation; + }): Promise { + this._ensureCloudEnvironment(); + + const browserSession = await this.createBrowserSession({ + timeout: options?.timeout, + proxy_location: options?.proxyLocation, + }); + + LOG.info("Launched new cloud browser session", { browser_session_id: browserSession.browser_session_id }); + + return this._connectToCloudBrowserSession(browserSession); + } + + async connectToCloudBrowserSession(browserSessionId: string): Promise { + this._ensureCloudEnvironment(); + + const browserSession = await this.getBrowserSession(browserSessionId); + + LOG.info("Connecting to existing cloud browser session", { browser_session_id: browserSession.browser_session_id }); + + return this._connectToCloudBrowserSession(browserSession); + } + + async useCloudBrowser(options?: { timeout?: number; proxyLocation?: ProxyLocation }): Promise { + this._ensureCloudEnvironment(); + + const browserSessions = await this.getBrowserSessions(); + const browserSession = browserSessions + .filter((s) => s.runnable_id == null) + .sort((a, b) => { + const aTime = a.started_at ? new Date(a.started_at).getTime() : 0; + const bTime = b.started_at ? new Date(b.started_at).getTime() : 0; + return bTime - aTime; + })[0]; + + if (!browserSession) { + LOG.info("No existing cloud browser session found, launching a new session"); + return this.launchCloudBrowser(options); + } + + LOG.info("Reusing existing cloud browser session", { browser_session_id: browserSession.browser_session_id }); + + return this._connectToCloudBrowserSession(browserSession); + } + + async connectToBrowserOverCdp(cdpUrl: string): Promise { + const browser = await chromium.connectOverCDP(cdpUrl); + const browserContext = browser.contexts()[0] ?? (await browser.newContext()); + + const skyvernBrowser = new SkyvernBrowser(this, browserContext, { browser, browserAddress: cdpUrl }); + this._browsers.add(skyvernBrowser); + return skyvernBrowser; + } + + async close(): Promise { + await Promise.all(Array.from(this._browsers).map((browser) => browser.close())); + this._browsers.clear(); + } + + _untrackBrowser(browser: SkyvernBrowser): void { + this._browsers.delete(browser); + } + + private _ensureCloudEnvironment(): void { + if (this._environment !== SkyvernEnvironment.Cloud && this._environment !== SkyvernEnvironment.Staging) { + throw new Error("Cloud browser sessions are supported only in the cloud environment"); + } + } + + private async _connectToCloudBrowserSession( + browserSession: SkyvernApi.BrowserSessionResponse, + ): Promise { + if (!browserSession.browser_address) { + throw new Error(`Browser address is missing for session ${browserSession.browser_session_id}`); + } + + const browser = await chromium.connectOverCDP(browserSession.browser_address, { + headers: { "x-api-key": this._apiKey }, + }); + const browserContext = browser.contexts()[0] ?? (await browser.newContext()); + + const skyvernBrowser = new SkyvernBrowser(this, browserContext, { + browser, + browserSessionId: browserSession.browser_session_id, + }); + this._browsers.add(skyvernBrowser); + return skyvernBrowser; + } + + private async _waitForRunCompletion(runId: string, timeoutSeconds: number): Promise { + const startTime = Date.now(); + const timeoutMs = timeoutSeconds * 1000; + + while (true) { + const run = await this.getRun(runId); + + // Check if the run is in a final state + const status = run.status; + if ( + status === "completed" || + status === "failed" || + status === "terminated" || + status === "timed_out" || + status === "canceled" + ) { + return run; + } + + // Check timeout + if (Date.now() - startTime >= timeoutMs) { + throw new Error(`Timeout waiting for run ${runId} to complete after ${timeoutSeconds} seconds`); + } + + // Wait before polling again + await new Promise((resolve) => setTimeout(resolve, 10000)); // 10 seconds + } + } +} diff --git a/skyvern-ts/client/src/library/SkyvernBrowser.ts b/skyvern-ts/client/src/library/SkyvernBrowser.ts new file mode 100644 index 00000000..85c15df0 --- /dev/null +++ b/skyvern-ts/client/src/library/SkyvernBrowser.ts @@ -0,0 +1,78 @@ +import type { Browser, BrowserContext, Page } from "playwright"; +import type { Skyvern } from "./Skyvern.js"; +import { SkyvernBrowserPageCore, type SkyvernBrowserPage } from "./SkyvernBrowserPage.js"; + +export class SkyvernBrowser { + private readonly _skyvern: Skyvern; + private readonly _browserContext: BrowserContext; + private readonly _browser?: Browser; + private readonly _browserSessionId?: string; + private readonly _browserAddress?: string; + + public workflowRunId?: string; + + constructor( + skyvern: Skyvern, + browserContext: BrowserContext, + options?: { + browser?: Browser; + browserSessionId?: string; + browserAddress?: string; + }, + ) { + this._skyvern = skyvern; + this._browserContext = browserContext; + this._browser = options?.browser; + this._browserSessionId = options?.browserSessionId; + this._browserAddress = options?.browserAddress; + } + + get browserSessionId(): string | undefined { + return this._browserSessionId; + } + + get browserAddress(): string | undefined { + return this._browserAddress; + } + + get skyvern(): Skyvern { + return this._skyvern; + } + + get context(): BrowserContext { + return this._browserContext; + } + + async getWorkingPage(): Promise { + const pages = this._browserContext.pages(); + const page = pages.length > 0 ? pages[pages.length - 1] : await this._browserContext.newPage(); + return this._createSkyvernPage(page); + } + + async newPage(): Promise { + const page = await this._browserContext.newPage(); + return this._createSkyvernPage(page); + } + + pages(): SkyvernBrowserPage[] { + return this._browserContext.pages().map((page) => SkyvernBrowserPageCore.create(this, page)); + } + + async close(): Promise { + if (this._browser) { + await this._browser.close(); + } else { + await this._browserContext.close(); + } + + if (this._browserSessionId) { + await this._skyvern.closeBrowserSession(this._browserSessionId); + } + + this._skyvern._untrackBrowser(this); + } + + private _createSkyvernPage(page: Page): SkyvernBrowserPage { + return SkyvernBrowserPageCore.create(this, page); + } +} diff --git a/skyvern-ts/client/src/library/SkyvernBrowserPage.ts b/skyvern-ts/client/src/library/SkyvernBrowserPage.ts new file mode 100644 index 00000000..d870808b --- /dev/null +++ b/skyvern-ts/client/src/library/SkyvernBrowserPage.ts @@ -0,0 +1,149 @@ +import type { Page } from "playwright"; +import type { SkyvernBrowser } from "./SkyvernBrowser.js"; +import { SkyvernBrowserPageAgent } from "./SkyvernBrowserPageAgent.js"; +import { SkyvernBrowserPageAi } from "./SkyvernBrowserPageAi.js"; + +export class SkyvernBrowserPageCore { + private readonly _browser: SkyvernBrowser; + private readonly _page: Page; + private readonly _ai: SkyvernBrowserPageAi; + public readonly agent: SkyvernBrowserPageAgent; + private readonly _proxy: SkyvernBrowserPage; + + private constructor(browser: SkyvernBrowser, page: Page) { + this._browser = browser; + this._page = page; + this._ai = new SkyvernBrowserPageAi(browser, page); + this.agent = new SkyvernBrowserPageAgent(browser, page); + + this._proxy = new Proxy(this, { + get(target, prop, receiver) { + if (prop in target) { + return Reflect.get(target, prop, receiver); + } + + const value = Reflect.get(target._page, prop, target._page); + if (typeof value === "function") { + return value.bind(target._page); + } + return value; + }, + }) as unknown as SkyvernBrowserPage; + } + + static create(browser: SkyvernBrowser, page: Page): SkyvernBrowserPage { + const instance = new SkyvernBrowserPageCore(browser, page); + return instance._proxy; + } + + get page(): Page { + return this._page; + } + + get browser(): SkyvernBrowser { + return this._browser; + } + + async click(selector: string, options?: Parameters[1]): Promise; + async click(options: { prompt: string } & Partial[1]>): Promise; + async click( + selectorOrOptions: string | ({ prompt: string } & Partial[1]>), + options?: Parameters[1], + ): Promise { + if (typeof selectorOrOptions === "string") { + return this._page.click(selectorOrOptions, options); + } else { + const { prompt, timeout, ...data } = selectorOrOptions; + await this._ai.aiClick({ + intention: prompt, + timeout, + data: Object.keys(data).length > 0 ? data : undefined, + }); + } + } + + async fill(selector: string, value: string, options?: Parameters[2]): Promise; + async fill(options: { prompt: string; value?: string } & Partial[2]>): Promise; + async fill( + selectorOrOptions: string | ({ prompt: string; value?: string } & Partial[2]>), + value?: string, + options?: Parameters[2], + ): Promise { + if (typeof selectorOrOptions === "string") { + if (value === undefined) { + throw new Error("value is required when selector is provided"); + } + return this._page.fill(selectorOrOptions, value, options); + } else { + const { prompt, value: fillValue, timeout, ...data } = selectorOrOptions; + await this._ai.aiInputText({ + value: fillValue, + intention: prompt, + timeout, + data: Object.keys(data).length > 0 ? data : undefined, + }); + } + } + + async selectOption( + selector: string, + values: string | string[], + options?: Parameters[2], + ): Promise; + async selectOption( + options: { prompt: string; value?: string } & Partial[2]>, + ): Promise; + async selectOption( + selectorOrOptions: string | ({ prompt: string; value?: string } & Partial[2]>), + values?: string | string[], + options?: Parameters[2], + ): Promise { + if (typeof selectorOrOptions === "string") { + if (values === undefined) { + throw new Error("value is required when selector is provided"); + } + return this._page.selectOption(selectorOrOptions, values, options); + } else { + const { prompt, value, timeout, ...data } = selectorOrOptions; + await this._ai.aiSelectOption({ + value, + intention: prompt, + timeout, + data: Object.keys(data).length > 0 ? data : undefined, + }); + return value ? [value] : []; + } + } + + async act(prompt: string): Promise { + return this._ai.aiAct(prompt); + } + + async extract(options: { + prompt: string; + schema?: Record | unknown[] | string; + errorCodeMapping?: Record; + intention?: string; + data?: string | Record; + }): Promise | unknown[] | string | null> { + return this._ai.aiExtract(options); + } + + async validate(prompt: string, model?: Record | string): Promise { + const normalizedModel: Record | undefined = + typeof model === "string" ? { modelName: model } : model; + return this._ai.aiValidate({ prompt, model: normalizedModel }); + } + + async prompt( + prompt: string, + schema?: Record, + model?: Record | string, + ): Promise | unknown[] | string | null> { + const normalizedModel: Record | undefined = + typeof model === "string" ? { modelName: model } : model; + return this._ai.aiPrompt({ prompt, schema, model: normalizedModel }); + } +} + +export type SkyvernBrowserPage = SkyvernBrowserPageCore & Page; diff --git a/skyvern-ts/client/src/library/SkyvernBrowserPageAgent.ts b/skyvern-ts/client/src/library/SkyvernBrowserPageAgent.ts new file mode 100644 index 00000000..5666e323 --- /dev/null +++ b/skyvern-ts/client/src/library/SkyvernBrowserPageAgent.ts @@ -0,0 +1,260 @@ +import type { Page } from "playwright"; +import type * as Skyvern from "../api/index.js"; +import { SkyvernEnvironment } from "../environments.js"; +import { DEFAULT_AGENT_HEARTBEAT_INTERVAL, DEFAULT_AGENT_TIMEOUT } from "./constants.js"; +import type { SkyvernBrowser } from "./SkyvernBrowser.js"; +import { LOG } from "./logger.js"; + +function getAppUrlForRun(runId: string): string { + return `https://app.skyvern.com/runs/${runId}`; +} + +export class SkyvernBrowserPageAgent { + private readonly _browser: SkyvernBrowser; + private readonly _page: Page; + + constructor(browser: SkyvernBrowser, page: Page) { + this._browser = browser; + this._page = page; + } + + async runTask( + prompt: string, + options?: { + engine?: Skyvern.RunEngine; + model?: Record; + url?: string; + webhookUrl?: string; + totpIdentifier?: string; + totpUrl?: string; + title?: string; + errorCodeMapping?: Record; + dataExtractionSchema?: Record | string; + maxSteps?: number; + timeout?: number; + }, + ): Promise { + LOG.info("AI run task", { prompt }); + + const taskRun = await this._browser.skyvern.runTask({ + "x-user-agent": "skyvern-sdk", + body: { + prompt: prompt, + engine: options?.engine, + model: options?.model, + url: options?.url ?? this._getPageUrl(), + webhook_url: options?.webhookUrl, + totp_identifier: options?.totpIdentifier, + totp_url: options?.totpUrl, + title: options?.title, + error_code_mapping: options?.errorCodeMapping, + data_extraction_schema: options?.dataExtractionSchema, + max_steps: options?.maxSteps, + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + }, + }); + + if (this._browser.skyvern.environment === SkyvernEnvironment.Cloud) { + LOG.info("AI task is running, this may take a while", { url: getAppUrlForRun(taskRun.run_id), run_id: taskRun.run_id }); + } else { + LOG.info("AI task is running, this may take a while", { run_id: taskRun.run_id }); + } + + const completedRun = await this._waitForRunCompletion( + taskRun.run_id, + options?.timeout ?? DEFAULT_AGENT_TIMEOUT, + ); + LOG.info("AI task finished", { run_id: completedRun.run_id, status: completedRun.status }); + + return completedRun as Skyvern.TaskRunResponse; + } + + async login( + credentialType: string, + options?: { + url?: string; + credentialId?: string; + bitwardenCollectionId?: string; + bitwardenItemId?: string; + onepasswordVaultId?: string; + onepasswordItemId?: string; + prompt?: string; + webhookUrl?: string; + totpIdentifier?: string; + totpUrl?: string; + extraHttpHeaders?: Record; + timeout?: number; + }, + ): Promise { + LOG.info("Starting AI login workflow", { credential_type: credentialType }); + + const workflowRun = await this._browser.skyvern.login( + { + credential_type: credentialType as Skyvern.SkyvernSchemasRunBlocksCredentialType, + url: options?.url ?? this._getPageUrl(), + credential_id: options?.credentialId, + bitwarden_collection_id: options?.bitwardenCollectionId, + bitwarden_item_id: options?.bitwardenItemId, + onepassword_vault_id: options?.onepasswordVaultId, + onepassword_item_id: options?.onepasswordItemId, + prompt: options?.prompt, + webhook_url: options?.webhookUrl, + totp_identifier: options?.totpIdentifier, + totp_url: options?.totpUrl, + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + extra_http_headers: options?.extraHttpHeaders, + }, + { + headers: { "x-user-agent": "skyvern-sdk" }, + }, + ); + + if (this._browser.skyvern.environment === SkyvernEnvironment.Cloud) { + LOG.info("AI login workflow is running, this may take a while", { url: getAppUrlForRun(workflowRun.run_id), run_id: workflowRun.run_id }); + } else { + LOG.info("AI login workflow is running, this may take a while", { run_id: workflowRun.run_id }); + } + + const completedRun = await this._waitForRunCompletion( + workflowRun.run_id, + options?.timeout ?? DEFAULT_AGENT_TIMEOUT, + ); + LOG.info("AI login workflow finished", { run_id: completedRun.run_id, status: completedRun.status }); + + return completedRun as Skyvern.WorkflowRunResponse; + } + + async downloadFiles( + prompt: string, + options?: { + url?: string; + downloadSuffix?: string; + downloadTimeout?: number; + maxStepsPerRun?: number; + webhookUrl?: string; + totpIdentifier?: string; + totpUrl?: string; + extraHttpHeaders?: Record; + timeout?: number; + }, + ): Promise { + LOG.info("Starting AI file download workflow", { navigation_goal: prompt }); + + const workflowRun = await this._browser.skyvern.downloadFiles( + { + navigation_goal: prompt, + url: options?.url ?? this._getPageUrl(), + download_suffix: options?.downloadSuffix, + download_timeout: options?.downloadTimeout, + max_steps_per_run: options?.maxStepsPerRun, + webhook_url: options?.webhookUrl, + totp_identifier: options?.totpIdentifier, + totp_url: options?.totpUrl, + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + extra_http_headers: options?.extraHttpHeaders, + }, + { + headers: { "x-user-agent": "skyvern-sdk" }, + }, + ); + + LOG.info("AI file download workflow is running, this may take a while", { run_id: workflowRun.run_id }); + + const completedRun = await this._waitForRunCompletion( + workflowRun.run_id, + options?.timeout ?? DEFAULT_AGENT_TIMEOUT, + ); + LOG.info("AI file download workflow finished", { run_id: completedRun.run_id, status: completedRun.status }); + + return completedRun as Skyvern.WorkflowRunResponse; + } + + async runWorkflow( + workflowId: string, + options?: { + parameters?: Record; + template?: boolean; + title?: string; + webhookUrl?: string; + totpUrl?: string; + totpIdentifier?: string; + timeout?: number; + }, + ): Promise { + LOG.info("Starting AI workflow", { workflow_id: workflowId }); + + const workflowRun = await this._browser.skyvern.runWorkflow( + { + "x-user-agent": "skyvern-sdk", + template: options?.template, + body: { + workflow_id: workflowId, + parameters: options?.parameters, + title: options?.title, + webhook_url: options?.webhookUrl, + totp_url: options?.totpUrl, + totp_identifier: options?.totpIdentifier, + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + }, + }, + { + headers: { "x-user-agent": "skyvern-sdk" }, + }, + ); + + if (this._browser.skyvern.environment === SkyvernEnvironment.Cloud) { + LOG.info("AI workflow is running, this may take a while", { url: getAppUrlForRun(workflowRun.run_id), run_id: workflowRun.run_id }); + } else { + LOG.info("AI workflow is running, this may take a while", { run_id: workflowRun.run_id }); + } + + const completedRun = await this._waitForRunCompletion( + workflowRun.run_id, + options?.timeout ?? DEFAULT_AGENT_TIMEOUT, + ); + LOG.info("AI workflow finished", { run_id: completedRun.run_id, status: completedRun.status }); + + return completedRun as Skyvern.WorkflowRunResponse; + } + + private async _waitForRunCompletion(runId: string, timeoutSeconds: number): Promise { + const startTime = Date.now(); + const timeoutMs = timeoutSeconds * 1000; + + while (true) { + const run = await this._browser.skyvern.getRun(runId); + + // Check if the run is in a final state + const status = run.status; + if ( + status === "completed" || + status === "failed" || + status === "terminated" || + status === "timed_out" || + status === "canceled" + ) { + return run; + } + + // Check timeout + if (Date.now() - startTime >= timeoutMs) { + throw new Error(`Timeout waiting for run ${runId} to complete after ${timeoutSeconds} seconds`); + } + + // Wait before polling again + await new Promise((resolve) => setTimeout(resolve, DEFAULT_AGENT_HEARTBEAT_INTERVAL * 1000)); + } + } + + private _getPageUrl(): string | undefined { + const url = this._page.url(); + if (url === "about:blank") { + return undefined; + } + return url; + } +} diff --git a/skyvern-ts/client/src/library/SkyvernBrowserPageAi.ts b/skyvern-ts/client/src/library/SkyvernBrowserPageAi.ts new file mode 100644 index 00000000..7a4d7315 --- /dev/null +++ b/skyvern-ts/client/src/library/SkyvernBrowserPageAi.ts @@ -0,0 +1,264 @@ +import type { Page } from "playwright"; +import type * as Skyvern from "../api/index.js"; +import type { SkyvernBrowser } from "./SkyvernBrowser.js"; +import { LOG } from "./logger.js"; + +export class SkyvernBrowserPageAi { + private readonly _browser: SkyvernBrowser; + private readonly _page: Page; + + constructor(browser: SkyvernBrowser, page: Page) { + this._browser = browser; + this._page = page; + } + + async aiClick(options: { + selector?: string; + intention: string; + data?: string | Record; + timeout?: number; + }): Promise { + LOG.info("AI click", { intention: options.intention, workflow_run_id: this._browser.workflowRunId }); + + const response = await this._browser.skyvern.runSdkAction({ + url: this._page.url(), + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + workflow_run_id: this._browser.workflowRunId, + action: { + type: "ai_click", + selector: options.selector, + intention: options.intention, + data: options.data, + timeout: options.timeout, + } as Skyvern.RunSdkActionRequestAction, + }); + + if (response.workflow_run_id) { + this._browser.workflowRunId = response.workflow_run_id; + } + + return response.result ? String(response.result) : options.selector || null; + } + + async aiInputText(options: { + selector?: string; + value?: string; + intention: string; + data?: string | Record; + totpIdentifier?: string; + totpUrl?: string; + timeout?: number; + }): Promise { + LOG.info("AI input text", { intention: options.intention, workflow_run_id: this._browser.workflowRunId }); + + const response = await this._browser.skyvern.runSdkAction({ + url: this._page.url(), + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + workflow_run_id: this._browser.workflowRunId, + action: { + type: "ai_input_text", + selector: options.selector, + value: options.value, + intention: options.intention, + data: options.data, + totp_identifier: options.totpIdentifier, + totp_url: options.totpUrl, + timeout: options.timeout, + } as Skyvern.RunSdkActionRequestAction, + }); + + if (response.workflow_run_id) { + this._browser.workflowRunId = response.workflow_run_id; + } + + return response.result ? String(response.result) : options.value || ""; + } + + async aiSelectOption(options: { + selector?: string; + value?: string; + intention: string; + data?: string | Record; + timeout?: number; + }): Promise { + LOG.info("AI select option", { intention: options.intention, workflow_run_id: this._browser.workflowRunId }); + + const response = await this._browser.skyvern.runSdkAction({ + url: this._page.url(), + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + workflow_run_id: this._browser.workflowRunId, + action: { + type: "ai_select_option", + selector: options.selector, + value: options.value, + intention: options.intention, + data: options.data, + timeout: options.timeout, + } as Skyvern.RunSdkActionRequestAction, + }); + + if (response.workflow_run_id) { + this._browser.workflowRunId = response.workflow_run_id; + } + + return response.result ? String(response.result) : options.value || ""; + } + + async aiUploadFile(options: { + selector?: string; + fileUrl?: string; + intention: string; + data?: string | Record; + timeout?: number; + }): Promise { + LOG.info("AI upload file", { intention: options.intention, workflow_run_id: this._browser.workflowRunId }); + + const response = await this._browser.skyvern.runSdkAction({ + url: this._page.url(), + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + workflow_run_id: this._browser.workflowRunId, + action: { + type: "ai_upload_file", + selector: options.selector, + file_url: options.fileUrl, + intention: options.intention, + data: options.data, + timeout: options.timeout, + } as Skyvern.RunSdkActionRequestAction, + }); + + if (response.workflow_run_id) { + this._browser.workflowRunId = response.workflow_run_id; + } + + return response.result ? String(response.result) : options.fileUrl || ""; + } + + async aiExtract(options: { + prompt: string; + extractSchema?: Record | unknown[] | string; + errorCodeMapping?: Record; + intention?: string; + data?: string | Record; + }): Promise | unknown[] | string | null> { + LOG.info("AI extract", { prompt: options.prompt, workflow_run_id: this._browser.workflowRunId }); + + const response = await this._browser.skyvern.runSdkAction({ + url: this._page.url(), + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + workflow_run_id: this._browser.workflowRunId, + action: { + type: "extract", + prompt: options.prompt, + extract_schema: options.extractSchema, + error_code_mapping: options.errorCodeMapping, + intention: options.intention, + data: options.data, + } as Skyvern.RunSdkActionRequestAction, + }); + + if (response.workflow_run_id) { + this._browser.workflowRunId = response.workflow_run_id; + } + + return (response.result as Record | unknown[] | string) || null; + } + + async aiValidate(options: { prompt: string; model?: Record }): Promise { + LOG.info("AI validate", { prompt: options.prompt, model: options.model, workflow_run_id: this._browser.workflowRunId }); + + const response = await this._browser.skyvern.runSdkAction({ + url: this._page.url(), + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + workflow_run_id: this._browser.workflowRunId, + action: { + type: "validate", + prompt: options.prompt, + model: options.model, + } as Skyvern.RunSdkActionRequestAction, + }); + + if (response.workflow_run_id) { + this._browser.workflowRunId = response.workflow_run_id; + } + + return response.result != null ? Boolean(response.result) : false; + } + + async aiAct(prompt: string): Promise { + LOG.info("AI act", { prompt, workflow_run_id: this._browser.workflowRunId }); + + const response = await this._browser.skyvern.runSdkAction({ + url: this._page.url(), + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + workflow_run_id: this._browser.workflowRunId, + action: { + type: "ai_act", + intention: prompt, + } as Skyvern.RunSdkActionRequestAction, + }); + + if (response.workflow_run_id) { + this._browser.workflowRunId = response.workflow_run_id; + } + } + + async aiLocateElement(prompt: string): Promise { + LOG.info("AI locate element", { prompt, workflow_run_id: this._browser.workflowRunId }); + + const response = await this._browser.skyvern.runSdkAction({ + url: this._page.url(), + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + workflow_run_id: this._browser.workflowRunId, + action: { + type: "locate_element", + prompt, + } as Skyvern.RunSdkActionRequestAction, + }); + + if (response.workflow_run_id) { + this._browser.workflowRunId = response.workflow_run_id; + } + + if (response.result && typeof response.result === "string") { + return response.result; + } + + return null; + } + + async aiPrompt(options: { + prompt: string; + schema?: Record; + model?: Record; + }): Promise | unknown[] | string | null> { + LOG.info("AI prompt", { prompt: options.prompt, model: options.model, workflow_run_id: this._browser.workflowRunId }); + + const response = await this._browser.skyvern.runSdkAction({ + url: this._page.url(), + browser_session_id: this._browser.browserSessionId, + browser_address: this._browser.browserAddress, + workflow_run_id: this._browser.workflowRunId, + action: { + type: "prompt", + prompt: options.prompt, + schema: options.schema, + model: options.model, + } as Skyvern.RunSdkActionRequestAction, + }); + + if (response.workflow_run_id) { + this._browser.workflowRunId = response.workflow_run_id; + } + + return (response.result as Record | unknown[] | string) || null; + } +} diff --git a/skyvern-ts/client/src/library/constants.ts b/skyvern-ts/client/src/library/constants.ts new file mode 100644 index 00000000..7024fea0 --- /dev/null +++ b/skyvern-ts/client/src/library/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_AGENT_TIMEOUT = 1800; // 30 minutes in seconds +export const DEFAULT_AGENT_HEARTBEAT_INTERVAL = 10; // 10 seconds +export const DEFAULT_CDP_PORT = 9222; diff --git a/skyvern-ts/client/src/library/index.ts b/skyvern-ts/client/src/library/index.ts new file mode 100644 index 00000000..715975aa --- /dev/null +++ b/skyvern-ts/client/src/library/index.ts @@ -0,0 +1,10 @@ +export { Skyvern } from "./Skyvern.js"; +export type { SkyvernOptions, RunTaskOptions, RunWorkflowOptions, LoginOptions, DownloadFilesOptions } from "./Skyvern.js"; +export { SkyvernBrowser } from "./SkyvernBrowser.js"; +export { SkyvernBrowserPageCore as SkyvernBrowserPageIml } from "./SkyvernBrowserPage.js"; +export type { SkyvernBrowserPage } from "./SkyvernBrowserPage.js"; +export { SkyvernBrowserPageAgent } from "./SkyvernBrowserPageAgent.js"; +export { SkyvernBrowserPageAi } from "./SkyvernBrowserPageAi.js"; +export { DEFAULT_AGENT_TIMEOUT, DEFAULT_AGENT_HEARTBEAT_INTERVAL, DEFAULT_CDP_PORT } from "./constants.js"; +export type { Logger } from "./logger.js"; +export { setLogger, LOG } from "./logger.js"; diff --git a/skyvern-ts/client/src/library/logger.ts b/skyvern-ts/client/src/library/logger.ts new file mode 100644 index 00000000..8fffcaaa --- /dev/null +++ b/skyvern-ts/client/src/library/logger.ts @@ -0,0 +1,71 @@ +export interface Logger { + debug(message: string, context?: Record): void; + info(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, context?: Record): void; +} + +function formatLogMessage(message: string, context?: Record): string { + const prefix = "[Skyvern]"; + + if (!context || Object.keys(context).length === 0) { + return `${prefix} ${message}`; + } + + const contextParts = Object.entries(context) + .filter(([, value]) => value !== null && value !== undefined) + .map(([key, value]) => { + if (typeof value === "object") { + return `${key}=${JSON.stringify(value)}`; + } + return `${key}=${value}`; + }) + .join(" "); + + if (contextParts.length === 0) { + return `${prefix} ${message}`; + } + + return `${prefix} ${message}\t${contextParts}`; +} + +const defaultLogger: Logger = { + debug: (message: string, context?: Record) => { + console.debug(formatLogMessage(message, context)); + }, + info: (message: string, context?: Record) => { + console.info(formatLogMessage(message, context)); + }, + warn: (message: string, context?: Record) => { + console.warn(formatLogMessage(message, context)); + }, + error: (message: string, context?: Record) => { + console.error(formatLogMessage(message, context)); + }, +}; + +let currentLogger: Logger = defaultLogger; + +export function setLogger(logger: Logger): void { + currentLogger = logger; +} + +class StructuredLogger { + debug(message: string, context?: Record): void { + currentLogger.debug(message, context); + } + + info(message: string, context?: Record): void { + currentLogger.info(message, context); + } + + warn(message: string, context?: Record): void { + currentLogger.warn(message, context); + } + + error(message: string, context?: Record): void { + currentLogger.error(message, context); + } +} + +export const LOG: StructuredLogger = new StructuredLogger();