-
Notifications
You must be signed in to change notification settings - Fork 1
feat(webfetch): route through fetch-use when BROWSER_USE_API_KEY is set #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
d87f175
feat(webfetch): route through fetch-use when BROWSER_USE_API_KEY is set
3cae8f4
fix(fetch-use): switch opt-out to experimental.fetch_use config; tigh…
278dba6
docs(BROWSER.md): note webfetch enhancement when BROWSER_USE_API_KEY …
9394b91
fix(fetch-use): fail on upstream HTTP error status to match native path
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| // FetchUse — Effect service that proxies HTTP through Browser Use's fetch-use | ||
| // cloud (Chrome JA4, HTTP/2 header order, session cookies). Decisions §3.3. | ||
| // `enabled` is true when BROWSER_USE_API_KEY is set; webfetch.ts combines | ||
| // this with the user's `experimental.fetch_use` opencode.json setting. | ||
|
|
||
| import { Context, Effect, Layer } from "effect" | ||
| import { HttpClient, HttpClientRequest } from "effect/unstable/http" | ||
|
|
||
| const ENDPOINT = "https://fetch.browser-use.com/fetch" | ||
|
|
||
| export interface FetchResult { | ||
| readonly body: ArrayBuffer | ||
| readonly contentType: string | ||
| } | ||
|
|
||
| interface FetchUseRaw { | ||
| status_code: number | ||
| headers?: Record<string, string[]> | ||
| body?: string | ||
| body_base64?: string | ||
| is_binary?: boolean | ||
| error?: string | ||
| } | ||
|
|
||
| export class Service extends Context.Service<Service, { | ||
| readonly enabled: boolean | ||
| readonly fetch: (url: string, opts: { timeoutMs: number }) => Effect.Effect<FetchResult, Error> | ||
| }>()("@browser-use/FetchUse") {} | ||
|
|
||
| export const layer = Layer.effect( | ||
| Service, | ||
| Effect.gen(function* () { | ||
| const http = yield* HttpClient.HttpClient | ||
| const apiKey = process.env.BROWSER_USE_API_KEY ?? "" | ||
| return Service.of({ | ||
| enabled: apiKey.length > 0, | ||
| fetch: (url, { timeoutMs }) => | ||
| Effect.gen(function* () { | ||
| const request = yield* HttpClientRequest.post(ENDPOINT).pipe( | ||
| HttpClientRequest.setHeaders({ "Content-Type": "application/json", "X-Browser-Use-API-Key": apiKey }), | ||
| HttpClientRequest.bodyJson({ url, timeout_ms: timeoutMs }), | ||
| ) | ||
| const response = yield* HttpClient.filterStatusOk(http).execute(request) | ||
| const data = (yield* response.json) as unknown as FetchUseRaw | ||
| if (data.error) return yield* Effect.fail(new Error(`fetch-use: ${data.error}`)) | ||
| // Mirror native path's filterStatusOk: surface upstream HTTP errors as failures. | ||
| if (data.status_code >= 400) return yield* Effect.fail(new Error(`fetch-use: HTTP ${data.status_code}`)) | ||
| const body = data.is_binary && data.body_base64 | ||
| ? (new Uint8Array(Buffer.from(data.body_base64, "base64")).buffer as ArrayBuffer) | ||
| : (new TextEncoder().encode(data.body ?? "").buffer as ArrayBuffer) | ||
| const ct = | ||
| Object.entries(data.headers ?? {}).find(([k]) => k.toLowerCase() === "content-type")?.[1]?.[0] ?? "" | ||
| return { body, contentType: ct } | ||
| }).pipe(Effect.mapError((e) => (e instanceof Error ? e : new Error(String(e))))), | ||
| }) | ||
| }), | ||
| ) | ||
|
|
||
| export * as FetchUse from "./fetch-use" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| // FetchUse smoke tests. | ||
| // | ||
| // Unit: layer is constructible, `enabled` reflects BROWSER_USE_API_KEY presence. | ||
| // Live: when the key is set, end-to-end POST to fetch.browser-use.com returns | ||
| // body bytes + content-type. Skipped without the key. Config-based | ||
| // opt-out (experimental.fetch_use=false) is enforced in webfetch.ts, | ||
| // not here. | ||
|
|
||
| import { expect, test } from "bun:test" | ||
| import { Effect, Layer } from "effect" | ||
| import { FetchHttpClient } from "effect/unstable/http" | ||
| import { FetchUse } from "../src/fetch-use" | ||
|
|
||
| const haveKey = !!process.env.BROWSER_USE_API_KEY | ||
|
|
||
| test("layer constructs and exposes `enabled` reflecting env", async () => { | ||
| const enabled = await Effect.gen(function* () { | ||
| return (yield* FetchUse.Service).enabled | ||
| }).pipe(Effect.provide(FetchUse.layer.pipe(Layer.provide(FetchHttpClient.layer))), Effect.runPromise) | ||
| expect(enabled).toBe(haveKey) | ||
| }) | ||
|
|
||
| test.skipIf(!haveKey)("live: fetches httpbin and returns body + content-type", async () => { | ||
| const result = await Effect.gen(function* () { | ||
| return yield* (yield* FetchUse.Service).fetch("https://httpbin.org/get", { timeoutMs: 30_000 }) | ||
| }).pipe(Effect.provide(FetchUse.layer.pipe(Layer.provide(FetchHttpClient.layer))), Effect.runPromise) | ||
|
|
||
| expect(result.contentType).toContain("application/json") | ||
| expect(JSON.parse(new TextDecoder().decode(result.body)).url).toBe("https://httpbin.org/get") | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,7 @@ | ||||||||||||||||||
| import { Effect, Schema } from "effect" | ||||||||||||||||||
| import { HttpClient, HttpClientRequest } from "effect/unstable/http" | ||||||||||||||||||
| import { FetchUse } from "@browser-use/bcode-browser/fetch-use" | ||||||||||||||||||
| import { Config } from "@/config/config" | ||||||||||||||||||
| import * as Tool from "./tool" | ||||||||||||||||||
| import TurndownService from "turndown" | ||||||||||||||||||
| import DESCRIPTION from "./webfetch.txt" | ||||||||||||||||||
|
|
@@ -24,6 +26,8 @@ export const WebFetchTool = Tool.define( | |||||||||||||||||
| Effect.gen(function* () { | ||||||||||||||||||
| const http = yield* HttpClient.HttpClient | ||||||||||||||||||
| const httpOk = HttpClient.filterStatusOk(http) | ||||||||||||||||||
| const fetchUse = yield* FetchUse.Service | ||||||||||||||||||
| const config = yield* Config.Service | ||||||||||||||||||
|
|
||||||||||||||||||
| return { | ||||||||||||||||||
| description: DESCRIPTION, | ||||||||||||||||||
|
|
@@ -47,61 +51,76 @@ export const WebFetchTool = Tool.define( | |||||||||||||||||
|
|
||||||||||||||||||
| const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) | ||||||||||||||||||
|
|
||||||||||||||||||
| // Build Accept header based on requested format with q parameters for fallbacks | ||||||||||||||||||
| let acceptHeader = "*/*" | ||||||||||||||||||
| switch (params.format) { | ||||||||||||||||||
| case "markdown": | ||||||||||||||||||
| acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" | ||||||||||||||||||
| break | ||||||||||||||||||
| case "text": | ||||||||||||||||||
| acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" | ||||||||||||||||||
| break | ||||||||||||||||||
| case "html": | ||||||||||||||||||
| acceptHeader = | ||||||||||||||||||
| "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" | ||||||||||||||||||
| break | ||||||||||||||||||
| default: | ||||||||||||||||||
| acceptHeader = | ||||||||||||||||||
| "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" | ||||||||||||||||||
| } | ||||||||||||||||||
| const headers = { | ||||||||||||||||||
| "User-Agent": | ||||||||||||||||||
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", | ||||||||||||||||||
| Accept: acceptHeader, | ||||||||||||||||||
| "Accept-Language": "en-US,en;q=0.9", | ||||||||||||||||||
| } | ||||||||||||||||||
| // BrowserCode: route through fetch-use when BROWSER_USE_API_KEY is | ||||||||||||||||||
| // set and the user hasn't opted out via experimental.fetch_use=false. | ||||||||||||||||||
| const useFu = fetchUse.enabled && (yield* config.get()).experimental?.fetch_use !== false | ||||||||||||||||||
| const { arrayBuffer, contentType } = yield* (useFu | ||||||||||||||||||
| ? fetchUse | ||||||||||||||||||
| .fetch(params.url, { timeoutMs: timeout }) | ||||||||||||||||||
| .pipe(Effect.map((r) => ({ arrayBuffer: r.body, contentType: r.contentType }))) | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The fetch-use branch ignores the fetched page status code, so HTTP error pages (e.g. 404/500) are treated as successful content instead of failing like the native path. Prompt for AI agents
Suggested change
|
||||||||||||||||||
| : Effect.gen(function* () { | ||||||||||||||||||
| // Build Accept header based on requested format with q parameters for fallbacks | ||||||||||||||||||
| let acceptHeader = "*/*" | ||||||||||||||||||
| switch (params.format) { | ||||||||||||||||||
| case "markdown": | ||||||||||||||||||
| acceptHeader = | ||||||||||||||||||
| "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" | ||||||||||||||||||
| break | ||||||||||||||||||
| case "text": | ||||||||||||||||||
| acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" | ||||||||||||||||||
| break | ||||||||||||||||||
| case "html": | ||||||||||||||||||
| acceptHeader = | ||||||||||||||||||
| "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" | ||||||||||||||||||
| break | ||||||||||||||||||
| default: | ||||||||||||||||||
| acceptHeader = | ||||||||||||||||||
| "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" | ||||||||||||||||||
| } | ||||||||||||||||||
| const headers = { | ||||||||||||||||||
| "User-Agent": | ||||||||||||||||||
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", | ||||||||||||||||||
| Accept: acceptHeader, | ||||||||||||||||||
| "Accept-Language": "en-US,en;q=0.9", | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers)) | ||||||||||||||||||
|
|
||||||||||||||||||
| // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) | ||||||||||||||||||
| const response = yield* httpOk.execute(request).pipe( | ||||||||||||||||||
| Effect.catchIf( | ||||||||||||||||||
| (err) => | ||||||||||||||||||
| err.reason._tag === "StatusCodeError" && | ||||||||||||||||||
| err.reason.response.status === 403 && | ||||||||||||||||||
| err.reason.response.headers["cf-mitigated"] === "challenge", | ||||||||||||||||||
| () => | ||||||||||||||||||
| httpOk.execute( | ||||||||||||||||||
| HttpClientRequest.get(params.url).pipe( | ||||||||||||||||||
| HttpClientRequest.setHeaders({ ...headers, "User-Agent": "browsercode" }), | ||||||||||||||||||
| const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers)) | ||||||||||||||||||
|
|
||||||||||||||||||
| // Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch) | ||||||||||||||||||
| const response = yield* httpOk.execute(request).pipe( | ||||||||||||||||||
| Effect.catchIf( | ||||||||||||||||||
| (err) => | ||||||||||||||||||
| err.reason._tag === "StatusCodeError" && | ||||||||||||||||||
| err.reason.response.status === 403 && | ||||||||||||||||||
| err.reason.response.headers["cf-mitigated"] === "challenge", | ||||||||||||||||||
| () => | ||||||||||||||||||
| httpOk.execute( | ||||||||||||||||||
| HttpClientRequest.get(params.url).pipe( | ||||||||||||||||||
| HttpClientRequest.setHeaders({ ...headers, "User-Agent": "browsercode" }), | ||||||||||||||||||
| ), | ||||||||||||||||||
| ), | ||||||||||||||||||
| ), | ||||||||||||||||||
| ), | ||||||||||||||||||
| ), | ||||||||||||||||||
| Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }), | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| // Check content length | ||||||||||||||||||
| const contentLength = response.headers["content-length"] | ||||||||||||||||||
| if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { | ||||||||||||||||||
| throw new Error("Response too large (exceeds 5MB limit)") | ||||||||||||||||||
| } | ||||||||||||||||||
| Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }), | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| // Check content length | ||||||||||||||||||
| const contentLength = response.headers["content-length"] | ||||||||||||||||||
| if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { | ||||||||||||||||||
| throw new Error("Response too large (exceeds 5MB limit)") | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const arrayBuffer = yield* response.arrayBuffer | ||||||||||||||||||
| if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { | ||||||||||||||||||
| throw new Error("Response too large (exceeds 5MB limit)") | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| return { arrayBuffer, contentType: response.headers["content-type"] || "" } | ||||||||||||||||||
| })) | ||||||||||||||||||
|
|
||||||||||||||||||
| const arrayBuffer = yield* response.arrayBuffer | ||||||||||||||||||
| if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { | ||||||||||||||||||
| throw new Error("Response too large (exceeds 5MB limit)") | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const contentType = response.headers["content-type"] || "" | ||||||||||||||||||
| const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" | ||||||||||||||||||
| const title = `${params.url} (${contentType})` | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Skip the live fetch-use test when the opt-out env var is set too, not just when the API key is missing.
Prompt for AI agents