Merge pull request #43 from germondai/40-bug-authenticated-proxy-fails-on-firefoxcamoufox-credentials-embedded-in-proxyserver-are-ignored-tier-34-ns_error_proxy_connection_refused

fix(proxy): support authenticated browser proxies
This commit is contained in:
Germond
2026-07-27 03:11:12 +02:00
committed by GitHub
6 changed files with 70 additions and 25 deletions
+1
View File
@@ -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
+1
View File
@@ -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"
+2 -1
View File
@@ -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<any> => {
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
+24
View File
@@ -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 } : {}),
}
}
@@ -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",
})
})
})
+3 -24
View File
@@ -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<ReturnType<typeof handle.browser.newContext>> } = {}
const state: { proxyContext?: Awaited<ReturnType<typeof newFreshContext>> } = {}
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()