Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 48 additions & 31 deletions packages/bcode-browser/src/fetch-use.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// 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.
// `enabled` selects this route when direct credentials are complete or any
// scoped-proxy setting is present. Partial proxy config stays selected and
// errors instead of silently falling back to native HTTP.

import { Context, Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"

const ENDPOINT = "https://fetch.browser-use.com/fetch"
const DEFAULT_ENDPOINT = "https://fetch.browser-use.com/fetch"

export interface FetchResult {
readonly body: ArrayBuffer
Expand All @@ -27,33 +28,49 @@ export class Service extends Context.Service<Service, {
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 const makeLayer = (options: { proxyUrl: string; apiKey: string; proxyToken: string }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const hasProxySetting = options.proxyUrl.length > 0 || options.proxyToken.length > 0
const proxyEnabled = options.proxyUrl.length > 0 && options.proxyToken.length > 0
const directEnabled = !hasProxySetting && options.apiKey.length > 0
return Service.of({
enabled: hasProxySetting || directEnabled,
fetch: (url, { timeoutMs }) =>
Effect.gen(function* () {
if (!proxyEnabled && !directEnabled) {
return yield* Effect.fail(new Error("fetch-use credentials are missing or partially configured"))
}
const auth = proxyEnabled
? { Authorization: `Bearer ${options.proxyToken}` }
: { "X-Browser-Use-API-Key": options.apiKey }
const request = yield* HttpClientRequest.post(proxyEnabled ? options.proxyUrl : DEFAULT_ENDPOINT).pipe(
HttpClientRequest.setHeaders({ "Content-Type": "application/json", ...auth }),
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 const layer = makeLayer({
proxyUrl: process.env.BROWSER_USE_FETCH_URL ?? "",
apiKey: process.env.BROWSER_USE_API_KEY ?? "",
proxyToken: process.env.BROWSER_USE_FETCH_TOKEN ?? "",
})

export * as FetchUse from "./fetch-use"
69 changes: 64 additions & 5 deletions packages/bcode-browser/test/fetch-use.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// FetchUse smoke tests.
//
// Unit: layer is constructible, `enabled` reflects BROWSER_USE_API_KEY presence.
// Unit: layer is constructible, `enabled` reflects direct and proxy credentials.
// 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-in (experimental.fetch_use=true) is enforced in webfetch.ts,
Expand All @@ -11,20 +11,79 @@ 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
const haveProxySetting = !!process.env.BROWSER_USE_FETCH_URL || !!process.env.BROWSER_USE_FETCH_TOKEN
const fetchUseSelected = haveProxySetting || !!process.env.BROWSER_USE_API_KEY

test("layer constructs and exposes `enabled` reflecting env", async () => {
test("layer selects fetch-use for direct or scoped-proxy configuration", 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)
expect(enabled).toBe(fetchUseSelected)
})

test.skipIf(!haveKey)("live: fetches httpbin and returns body + content-type", async () => {
test.skipIf(!process.env.BROWSER_USE_API_KEY)("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")
})

test("scoped proxy token uses bearer auth instead of the API-key header", async () => {
const request = { authorization: "", apiKey: "", body: {} as Record<string, unknown> }
const server = Bun.serve({
port: 0,
fetch: async (incoming) => {
request.authorization = incoming.headers.get("authorization") ?? ""
request.apiKey = incoming.headers.get("x-browser-use-api-key") ?? ""
request.body = (await incoming.json()) as Record<string, unknown>
return Response.json({
status_code: 200,
headers: { "content-type": ["text/plain"] },
body: "proxied",
})
},
})

try {
const result = await Effect.gen(function* () {
const service = yield* FetchUse.Service
expect(service.enabled).toBe(true)
return yield* service.fetch("https://example.com/page", { timeoutMs: 12_345 })
}).pipe(
Effect.provide(
FetchUse.makeLayer({
proxyUrl: server.url.toString(),
apiKey: "",
proxyToken: "test-proxy-token",
}).pipe(Layer.provide(FetchHttpClient.layer)),
),
Effect.runPromise,
)

expect(request.authorization).toBe("Bearer test-proxy-token")
expect(request.apiKey).toBe("")
expect(request.body).toEqual({ url: "https://example.com/page", timeout_ms: 12_345 })
expect(new TextDecoder().decode(result.body)).toBe("proxied")
} finally {
server.stop(true)
}
})

test.each([
{ proxyUrl: "https://proxy.example/fetch", proxyToken: "", apiKey: "test-direct-key" },
{ proxyUrl: "", proxyToken: "test-proxy-token", apiKey: "test-direct-key" },
])("partial proxy configuration fails closed", async (options) => {
const result = await Effect.gen(function* () {
const service = yield* FetchUse.Service
// Keep this route selected so webfetch cannot silently use native HTTP.
expect(service.enabled).toBe(true)
return yield* Effect.flip(service.fetch("https://example.com", { timeoutMs: 1_000 }))
}).pipe(
Effect.provide(FetchUse.makeLayer(options).pipe(Layer.provide(FetchHttpClient.layer))),
Effect.runPromise,
)

expect(result.message).toContain("partially configured")
})
Loading