diff --git a/.fernignore b/.fernignore index 8d1acbe..5ff5d16 100644 --- a/.fernignore +++ b/.fernignore @@ -1,6 +1,9 @@ # Specify files that shouldn't be modified by Fern src/wrapper/ +test/wrapper/ +src/index.ts examples/ .vscode/ -.gitignore \ No newline at end of file +.gitignore +jest.config.mjs \ No newline at end of file diff --git a/README.md b/README.md index b66e3f6..3df6d80 100644 --- a/README.md +++ b/README.md @@ -57,26 +57,78 @@ for (const post of result.parsed.posts) { ### Streaming Agent Updates +> You can use the `stream` method to get the latest step taken on every change. + ```ts const task = await browseruse.tasks.createTask({ task: "Search for the top 10 Hacker News posts and return the title and url.", schema: TaskOutput, }); -for await (const msg of task.stream()) { - switch (msg.status) { - case "started": - case "paused": - case "stopped": - console.log(`running: ${msg}`); - break; +for await (const step of task.stream()) { + console.log(step); +} - case "finished": - console.log(`done:`); +const result = await task.complete(); - for (const post of msg.parsed.posts) { - console.log(`${post.title} - ${post.url}`); - } +for (const post of result.parsed.posts) { + console.log(`${post.title} - ${post.url}`); +} +``` + +### Watching Agent Updates + +> You can use the `watch` method to get the latest update on every change. + +```ts +const task = await browseruse.tasks.createTask({ + task: "Search for the top 10 Hacker News posts and return the title and url.", + schema: TaskOutput, +}); + +for await (const update of task.watch()) { + console.log(update); + + if (update.data.status === "finished") { + for (const post of update.data.parsed.posts) { + console.log(`${post.title} - ${post.url}`); + } + } +} +``` + +## Webhook Verification + +> We encourage you to use the SDK functions that verify and parse webhook events. + +```ts +import { verifyWebhookEventSignature, type WebhookAgentTaskStatusUpdatePayload } from "browser-use-sdk"; + +export async function POST(req: Request) { + const signature = req.headers["x-browser-use-signature"] as string; + const timestamp = req.headers["x-browser-use-timestamp"] as string; + + const event = await verifyWebhookEventSignature( + { + body, + signature, + timestamp, + }, + { + secret: SECRET_KEY, + }, + ); + + if (!event.ok) { + return; + } + + switch (event.event.type) { + case "agent.task.status_update": + break; + case "test": + break; + default: break; } } diff --git a/examples/retrieve.ts b/examples/retrieve.ts index cd9d2c2..9e03e33 100755 --- a/examples/retrieve.ts +++ b/examples/retrieve.ts @@ -11,7 +11,6 @@ env(); // gets API Key from environment variable BROWSER_USE_API_KEY const browseruse = new BrowserUseClient({ apiKey: process.env.BROWSER_USE_API_KEY!, - environment: "https://api.browser-use.com/api/v2", }); // Basic --------------------------------------------------------------------- @@ -23,7 +22,8 @@ async function basic() { // Create Task const rsp = await browseruse.tasks.createTask({ task: "What's the weather in SF and what's the temperature?", - agent: { llm: "gemini-2.5-flash" }, + llm: "gemini-2.5-flash", + schema: TaskOutput, }); poll: do { @@ -67,7 +67,7 @@ async function structured() { const rsp = await browseruse.tasks.createTask({ task: "What's the weather in SF and what's the temperature?", schema: TaskOutput, - agent: { llm: "gpt-4.1" }, + llm: "gpt-4.1", }); poll: do { diff --git a/examples/run.ts b/examples/run.ts index 34a15b2..bcf7275 100755 --- a/examples/run.ts +++ b/examples/run.ts @@ -10,7 +10,6 @@ env(); // gets API Key from environment variable BROWSER_USE_API_KEY const browseruse = new BrowserUseClient({ apiKey: process.env.BROWSER_USE_API_KEY!, - environment: "https://api.browser-use.com/api/v2", }); // Basic --------------------------------------------------------------------- @@ -48,7 +47,7 @@ async function structured() { const rsp = await browseruse.tasks.createTask({ task: "Search for the top 10 Hacker News posts and return the title and url!", schema: TaskOutput, - agent: { llm: "gpt-4.1" }, + llm: "gpt-4.1", }); const result = await rsp.complete(); diff --git a/examples/stream.ts b/examples/stream.ts index 362592b..dfc4ac0 100755 --- a/examples/stream.ts +++ b/examples/stream.ts @@ -10,7 +10,6 @@ env(); const browseruse = new BrowserUseClient({ apiKey: process.env.BROWSER_USE_API_KEY!, - environment: "https://api.browser-use.com/api/v2", }); // Basic --------------------------------------------------------------------- @@ -19,16 +18,39 @@ async function basic() { console.log("Basic: Creating task and starting stream..."); const task = await browseruse.tasks.createTask({ - task: "What's the weather in SF and what's the temperature?", - agent: { llm: "gemini-2.5-flash" }, + task: "What's the weather and temperature in SF, NY, and LA?", + llm: "gemini-2.5-flash", }); - for await (const msg of task.stream()) { - console.log(msg); + console.log(`task.id: ${task.id}`); + + const counter = { current: 0 }; + + for await (const step of task.stream()) { + console.log(`STREAM 1: ${step.number}`); + + counter.current++; + + if (counter.current === 2) { + break; + } + } + + for await (const step of task.stream()) { + counter.current++; + + console.log(`STREAM 2: ${step.number}`); } const result = await task.complete(); + if (counter.current <= result.steps.length || counter.current !== result.steps.length + 2) { + console.log(`counter.current: ${counter.current}, result.steps.length: ${result.steps.length}`); + throw new Error( + "Basic: Stream does not run as expected! Each step should be relogged whenever stream restarts!", + ); + } + console.log("Basic: Stream completed"); console.log(result.output); } @@ -51,8 +73,8 @@ async function structured() { const task = await browseruse.tasks.createTask({ task: "Extract top 10 Hacker News posts and return the title, url, and score", + llm: "gpt-4.1", schema: TaskOutput, - agent: { llm: "gpt-4.1" }, }); for await (const msg of task.stream()) { diff --git a/examples/watch.ts b/examples/watch.ts index cc3816e..80c500d 100755 --- a/examples/watch.ts +++ b/examples/watch.ts @@ -10,7 +10,6 @@ env(); // gets API Key from environment variable BROWSER_USE_API_KEY const browseruse = new BrowserUseClient({ apiKey: process.env.BROWSER_USE_API_KEY!, - environment: "https://api.browser-use.com/api/v2", }); // Basic --------------------------------------------------------------------- @@ -20,7 +19,7 @@ async function basic() { const task = await browseruse.tasks.createTask({ task: "What's the weather in SF and what's the temperature?", - agent: { llm: "gemini-2.5-flash" }, + llm: "gemini-2.5-flash", }); for await (const msg of task.watch()) { @@ -53,7 +52,7 @@ async function structured() { const task = await browseruse.tasks.createTask({ task: "Extract top 10 Hacker News posts and return the title, url, and score", schema: TaskOutput, - agent: { llm: "gpt-4.1" }, + llm: "gpt-4.1", }); for await (const msg of task.watch()) { diff --git a/jest.config.mjs b/jest.config.mjs index b692700..cd0b7e8 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -11,7 +11,7 @@ export default { "^(\.{1,2}/.*)\.js$": "$1", }, roots: ["/tests"], - testPathIgnorePatterns: ["\.browser\.(spec|test)\.[jt]sx?$", "/tests/wire/"], + testPathIgnorePatterns: ["\.browser\.(spec|test)\.[jt]sx?$", "/tests/wire/", "/tests/wrapper"], setupFilesAfterEnv: [], }, { @@ -25,7 +25,6 @@ export default { testMatch: ["/tests/unit/**/?(*.)+(browser).(spec|test).[jt]s?(x)"], setupFilesAfterEnv: [], }, - , { displayName: "wire", preset: "ts-jest", @@ -36,6 +35,13 @@ export default { roots: ["/tests/wire"], setupFilesAfterEnv: ["/tests/mock-server/setup.ts"], }, + { + displayName: "wrapper", + preset: "ts-jest", + testEnvironment: "node", + moduleNameMapper: {}, + roots: ["/tests/wrapper"], + }, ], workerThreads: false, passWithNoTests: true, diff --git a/src/index.ts b/src/index.ts index 72f356a..4a7b3f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ export * as BrowserUse from "./api/index.js"; -export { BrowserUseError, BrowserUseTimeoutError } from "./errors/index.js"; -export { BrowserUseClient } from "./Client.js"; export { BrowserUseEnvironment } from "./environments.js"; +export { BrowserUseError, BrowserUseTimeoutError } from "./errors/index.js"; +export { BrowserUseClient } from "./wrapper/BrowserUseClient.js"; +export type { WrappedTaskFnsWithoutSchema, WrappedTaskFnsWithSchema } from "./wrapper/lib/parse.js"; +export { createWebhookSignature, verifyWebhookEventSignature } from "./wrapper/lib/webhooks.js"; +export type { Webhook, WebhookAgentTaskStatusUpdatePayload, WebhookTestPayload } from "./wrapper/lib/webhooks.js"; diff --git a/src/wrapper/lib/parse.ts b/src/wrapper/lib/parse.ts index beb1d0f..66e174e 100644 --- a/src/wrapper/lib/parse.ts +++ b/src/wrapper/lib/parse.ts @@ -169,6 +169,25 @@ export function wrapCreateTaskResponse( } while (true); } + /** + * Streams the steps of the task and closes when the task is finished. + * + * @description Logs each step of the task exactly once. If you start the stream again, it will log the steps again. + */ + async function* stream( + config?: { interval?: number }, + options?: RequestOptions, + ): AsyncGenerator { + const steps: { total: number } = { total: 0 }; + + for await (const msg of _watch(response.id, config, options)) { + for (let i = steps.total; i < msg.data.steps.length; i++) { + yield msg.data.steps[i] satisfies BrowserUse.TaskStepView; + } + steps.total = msg.data.steps.length; + } + } + function watch( schema: T, config?: PollConfig, @@ -252,24 +271,6 @@ export function wrapCreateTaskResponse( throw new Error("Task did not finish"); } - async function* stream( - config?: { interval?: number }, - options?: RequestOptions, - ): AsyncGenerator { - const step: { current: number } = { current: 0 }; - - const interval = config?.interval ?? 2000; - - for await (const msg of _watch(response.id, { interval }, options)) { - if (msg.data.steps.length > step.current) { - step.current = msg.data.steps.length; - - const lastStepIdx = msg.data.steps.length - 1; - yield msg.data.steps[lastStepIdx] satisfies BrowserUse.TaskStepView; - } - } - } - // NOTE: Finally, we return the wrapped task response. if (schema == null) { diff --git a/src/wrapper/lib/webhooks.ts b/src/wrapper/lib/webhooks.ts new file mode 100644 index 0000000..b253b2b --- /dev/null +++ b/src/wrapper/lib/webhooks.ts @@ -0,0 +1,119 @@ +import { createHmac } from "crypto"; +import stringify from "fast-json-stable-stringify"; +import { z } from "zod"; + +// https://docs.browser-use.com/cloud/webhooks + +// + +export const zWebhookTimestamp = z.iso.datetime({ offset: true, local: true }); + +// test + +export const zWebhookTestPayload = z.object({ + test: z.literal("ok"), +}); + +export type WebhookTestPayload = z.infer; + +export const zWebhookTest = z.object({ + type: z.literal("test"), + timestamp: zWebhookTimestamp, + payload: zWebhookTestPayload, +}); + +// agent.task.status_update + +export const zWebhookAgentTaskStatusUpdatePayloadMetadata = z.record(z.string(), z.unknown()).optional(); + +export const zWebhookAgentTaskStatusUpdatePayloadStatus = z.literal([ + "initializing", + "started", + "paused", + "stopped", + "finished", +]); + +export const zWebhookAgentTaskStatusUpdatePayload = z.object({ + session_id: z.string(), + task_id: z.string(), + status: zWebhookAgentTaskStatusUpdatePayloadStatus, + metadata: zWebhookAgentTaskStatusUpdatePayloadMetadata, +}); + +export type WebhookAgentTaskStatusUpdatePayload = z.infer; + +export const zWebhookAgentTaskStatusUpdate = z.object({ + type: z.literal("agent.task.status_update"), + timestamp: zWebhookTimestamp, + payload: zWebhookAgentTaskStatusUpdatePayload, +}); + +// + +export const zWebhookSchema = z.discriminatedUnion("type", [ + // + zWebhookTest, + zWebhookAgentTaskStatusUpdate, +]); + +export type Webhook = z.infer; + +// Signature + +/** + * Utility function that validates the received Webhook event/ + */ +export async function verifyWebhookEventSignature( + evt: { + body: string | object; + signature: string; + timestamp: string; + }, + cfg: { secret: string }, +): Promise<{ ok: true; event: Webhook } | { ok: false }> { + try { + const json = typeof evt.body === "string" ? JSON.parse(evt.body) : evt.body; + const event = await zWebhookSchema.safeParseAsync(json); + + if (event.success === false) { + return { ok: false }; + } + + const signature = createWebhookSignature({ + payload: event.data.payload, + timestamp: evt.timestamp, + secret: cfg.secret, + }); + + // Compare signatures using timing-safe comparison + if (evt.signature !== signature) { + return { ok: false }; + } + + return { ok: true, event: event.data }; + } catch (err) { + console.error(err); + return { ok: false }; + } +} + +/** + * Creates a webhook signature for the given payload, timestamp, and secret. + */ +export function createWebhookSignature({ + payload, + timestamp, + secret, +}: { + payload: unknown; + timestamp: string; + secret: string; +}): string { + const dump = stringify(payload); + const message = `${timestamp}.${dump}`; + + const hmac = createHmac("sha256", secret); + hmac.update(message); + return hmac.digest("hex"); +} diff --git a/tests/wrapper/webhooks.test.ts b/tests/wrapper/webhooks.test.ts new file mode 100644 index 0000000..cec4418 --- /dev/null +++ b/tests/wrapper/webhooks.test.ts @@ -0,0 +1,114 @@ +import { + createWebhookSignature, + verifyWebhookEventSignature, + zWebhookSchema, + zWebhookTimestamp, +} from "../../src/wrapper/lib/webhooks"; + +describe("webhooks", () => { + describe("parse", () => { + test("timestamp", () => { + expect(zWebhookTimestamp.parse("2025-05-25T09:22:22.269116+00:00")).toBeDefined(); + expect(zWebhookTimestamp.parse("2025-08-15T18:09:11.881540")).toBeDefined(); + }); + + test("agent.task.status_update", () => { + const MOCK: unknown = { + type: "agent.task.status_update", + timestamp: "2025-05-25T09:22:22.269116+00:00", + payload: { + session_id: "cd9cc7bf-e3af-4181-80a2-73f083bc94b4", + task_id: "5b73fb3f-a3cb-4912-be40-17ce9e9e1a45", + status: "finished", + metadata: { + campaign: "q4-automation", + team: "marketing", + }, + }, + }; + + const response = zWebhookSchema.parse(MOCK); + + expect(response).toBeDefined(); + }); + + test("test", () => { + const MOCK: unknown = { + type: "test", + timestamp: "2025-05-25T09:22:22.269116+00:00", + payload: { test: "ok" }, + }; + + const response = zWebhookSchema.parse(MOCK); + + expect(response).toBeDefined(); + }); + + test("invalid", () => { + const MOCK: unknown = { + type: "invalid", + timestamp: "2025-05-25T09:22:22.269116+00:00", + payload: { test: "ok" }, + }; + + expect(() => zWebhookSchema.parse(MOCK)).toThrow(); + }); + }); + + describe("verify", () => { + test("correctly calculates signature", async () => { + const timestamp = "2025-05-26:22:22.269116+00:00"; + + const MOCK = { + type: "agent.task.status_update", + timestamp: "2025-05-25T09:22:22.269116+00:00", + payload: { + session_id: "cd9cc7bf-e3af-4181-80a2-73f083bc94b4", + task_id: "5b73fb3f-a3cb-4912-be40-17ce9e9e1a45", + status: "finished", + metadata: { + campaign: "q4-automation", + team: "marketing", + }, + }, + }; + + const signature = createWebhookSignature({ + payload: MOCK.payload, + secret: "secret", + timestamp, + }); + + const validJSON = await verifyWebhookEventSignature( + { + body: MOCK, + signature: signature, + timestamp, + }, + { secret: "secret" }, + ); + + const validString = await verifyWebhookEventSignature( + { + body: JSON.stringify(MOCK), + signature: signature, + timestamp, + }, + { secret: "secret" }, + ); + + const invalid = await verifyWebhookEventSignature( + { + body: JSON.stringify(MOCK), + signature: "invalid", + timestamp, + }, + { secret: "secret" }, + ); + + expect(validJSON.ok).toBe(true); + expect(validString.ok).toBe(true); + expect(invalid.ok).toBe(false); + }); + }); +});