From 78836d6ed770a029416bcf2e1509d6f892bca52a Mon Sep 17 00:00:00 2001 From: germondai Date: Mon, 27 Jul 2026 03:08:16 +0200 Subject: [PATCH] fix(proxy): support authenticated browser proxies --- CHANGELOG.md | 1 + packages/browser/src/index.ts | 1 + packages/browser/src/pool.ts | 3 +- packages/browser/src/proxy.ts | 24 +++++++++++++ packages/browser/tests/freshContext.test.ts | 39 +++++++++++++++++++++ packages/tiers/src/tiers/4.ts | 27 ++------------ 6 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 packages/browser/src/proxy.ts create mode 100644 packages/browser/tests/freshContext.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5d152..22c8ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reject non-object request bodies and missing, non-string, or blank `url` values with HTTP 400 before scraper-tier execution (#34). - Recover stalled browser checkouts and bound browser close/launch operations so wedged Firefox processes cannot silently exhaust the pool (#36, #37). - Report HTTP 503 from `/health` whenever the browser pool has no live capacity. +- Pass authenticated proxy credentials to Firefox separately from the proxy server URL in Tier 3 and Tier 4 (#40). ## [1.2.0] - 2026-07-26 diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index d744f43..fd76370 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -7,4 +7,5 @@ export { } from "./persistentContextCache" export type { BrowserHandle } from "./pool" export { BrowserPool, newFreshContext, PoolExhaustedError } from "./pool" +export { type PlaywrightProxy, toPlaywrightProxy } from "./proxy" export { SessionCache } from "./session" diff --git a/packages/browser/src/pool.ts b/packages/browser/src/pool.ts index c9c6a96..2420bdc 100644 --- a/packages/browser/src/pool.ts +++ b/packages/browser/src/pool.ts @@ -1,6 +1,7 @@ import type { BrowserHandle, PoolBrowser, PoolStats } from "@trawl/types" import { Camoufox } from "camoufox-js" import { FINGERPRINT_POOL } from "./fingerprint" +import { toPlaywrightProxy } from "./proxy" // camoufox-js wraps Playwright but doesn't re-export Browser/BrowserContext types. // The pool accepts any structurally-compatible browser (Playwright OR patchright) — @@ -589,7 +590,7 @@ export class BrowserPool { export const newFreshContext = async (browser: any, options?: { proxy?: string }): Promise => { const context = await browser.newContext({ viewport: null, - ...(options?.proxy ? { proxy: { server: options.proxy } } : {}), + ...(options?.proxy ? { proxy: toPlaywrightProxy(options.proxy) } : {}), }) await context.addInitScript(() => { window.onerror = () => true diff --git a/packages/browser/src/proxy.ts b/packages/browser/src/proxy.ts new file mode 100644 index 0000000..4846512 --- /dev/null +++ b/packages/browser/src/proxy.ts @@ -0,0 +1,24 @@ +export interface PlaywrightProxy { + server: string + username?: string + password?: string +} + +const decodeCredential = (value: string): string => { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +export const toPlaywrightProxy = (proxyUrl: string): PlaywrightProxy => { + const url = new URL(proxyUrl) + const username = url.username ? decodeCredential(url.username) : undefined + const password = url.password ? decodeCredential(url.password) : undefined + return { + server: `${url.protocol}//${url.host}`, + ...(username ? { username } : {}), + ...(password ? { password } : {}), + } +} diff --git a/packages/browser/tests/freshContext.test.ts b/packages/browser/tests/freshContext.test.ts new file mode 100644 index 0000000..abd49f6 --- /dev/null +++ b/packages/browser/tests/freshContext.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { newFreshContext } from "../src/pool" +import { toPlaywrightProxy } from "../src/proxy" + +describe("newFreshContext", () => { + test("passes authenticated proxy credentials separately from the server URL", async () => { + let receivedOptions: unknown + const context = { addInitScript: async () => {} } + const browser = { + newContext: async (options: unknown) => { + receivedOptions = options + return context + }, + } + + expect(await newFreshContext(browser, { proxy: "http://user:p%40ss@proxy.example.com:8080" })).toBe(context) + expect(receivedOptions).toMatchObject({ + proxy: { + server: "http://proxy.example.com:8080", + username: "user", + password: "p@ss", + }, + }) + }) + + test("keeps unauthenticated proxy URLs as server-only options", () => { + expect(toPlaywrightProxy("https://proxy.example.com:8443")).toEqual({ + server: "https://proxy.example.com:8443", + }) + }) + + test("preserves SOCKS5 and IPv6 endpoints while decoding credentials", () => { + expect(toPlaywrightProxy("socks5://user%40org:p%3Aass@[2001:db8::1]:1080")).toEqual({ + server: "socks5://[2001:db8::1]:1080", + username: "user@org", + password: "p:ass", + }) + }) +}) diff --git a/packages/tiers/src/tiers/4.ts b/packages/tiers/src/tiers/4.ts index e0089fb..348f907 100644 --- a/packages/tiers/src/tiers/4.ts +++ b/packages/tiers/src/tiers/4.ts @@ -1,5 +1,5 @@ import type { BrowserHandle } from "@trawl/browser" -import { FINGERPRINT } from "@trawl/browser" +import { FINGERPRINT, newFreshContext } from "@trawl/browser" import type { Cookie, TierResult } from "@trawl/types" import { solvePageCaptchas } from "../solvers" import { waitForAkamaiResolution } from "../utils/akamaiWait" @@ -47,32 +47,11 @@ export async function runTier4( // Proxies must be set at context creation time in Playwright — they cannot be // applied per-request. We create a fresh context here and close it when done, // leaving the pool's shared context untouched. - const state: { proxyContext?: Awaited> } = {} + const state: { proxyContext?: Awaited> } = {} try { - // Camoufox handles fingerprinting at the C++ level — only the proxy needs to - // be set at context creation (Playwright requires proxy at context init time). - const proxyContext = await handle.browser.newContext({ - proxy: { server: proxyUrl }, - viewport: null, - }) + const proxyContext = await newFreshContext(handle.browser, { proxy: proxyUrl }) state.proxyContext = proxyContext - await proxyContext.addInitScript(() => { - window.onerror = () => true - window.addEventListener( - "unhandledrejection", - (e: PromiseRejectionEvent) => { - e.preventDefault() - }, - true, - ) - const _orig = Element.prototype.attachShadow - Element.prototype.attachShadow = function (init: ShadowRootInit) { - const r = _orig.call(this, init) - Object.defineProperty(this, "shadowRootUnl", { configurable: true, value: r }) - return r - } - }) const page = await proxyContext.newPage()