diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts index 567e22338a..8ec46270a5 100644 --- a/open-sse/services/browserBackedChat.ts +++ b/open-sse/services/browserBackedChat.ts @@ -1,187 +1,25 @@ -/** - * browserBackedChat.ts — Provider-agnostic browser-backed chat helper. - * - * Opens a page on a shared browser context, navigates to the provider's - * chat page, types the user's message, clicks Send, and returns the - * upstream SSE/JSON response body as a Node Response. - * - * Used by duckduckgo-web and claude-web executors when - * OMNIROUTE_BROWSER_POOL=on (or WEB_COOKIE_USE_BROWSER=1) is set and - * the user wants guaranteed live working from this environment, even at - * the cost of 5-15s of browser navigation overhead per request. - * - * The browser solves the provider's challenge natively (VQD, Cloudflare - * Turnstile, etc.) by computing real DOM measurement values. The - * Node-side challenge solver in duckduckgo-web.ts still runs as a - * first-line best-effort; this module is the fallback. - */ - import { Buffer } from "node:buffer"; -import { - acquireBrowserContext, - openPage, - readPageResponseBody, - shutdownPool, - type PooledContext, -} from "./browserPool.ts"; import tlsClient from "../utils/tlsClient.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { resolveHttpBackedChatFingerprint } from "./httpBackedChatFingerprint.ts"; -// Safety constants -const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB -const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; // Cache fresh cookies for 5 minutes -const COOKIE_POLL_INTERVAL_MS = 500; // Poll for cookies every 500ms -const COOKIE_POLL_TIMEOUT_MS = 5000; // Max poll time for cookies -const CIRCUIT_BASE_COOLDOWN_MS = 30_000; // 30s base cooldown -const CIRCUIT_MAX_COOLDOWN_MS = 600_000; // 10 min max cooldown - -// Cookie cache — avoids repeated browser launches when cookies are still valid -interface CachedCookies { - cookieString: string; - expiresAt: number; - domain: string; -} -const cookieCache = new Map(); - -function getCachedCookies(domain: string): string | null { - const cached = cookieCache.get(domain); - if (cached && Date.now() < cached.expiresAt) return cached.cookieString; - cookieCache.delete(domain); - return null; -} - -function setCachedCookies(domain: string, cookieString: string, ttlMs?: number): void { - cookieCache.set(domain, { - cookieString, - expiresAt: Date.now() + (ttlMs ?? COOKIE_CACHE_TTL_MS), - domain, - }); -} - -// Dedup pending cookie refreshes per pool key -const pendingRefreshes = new Map>(); - -// Test-only injection point. Tests call __setBrowserBackedChatOverrideForTesting() -// to replace the real browser-backed chat with a mock; production never touches this. -let testOverride: ((req: BrowserBackedChatRequest) => Promise) | null = - null; - -let httpOverride: ((req: BrowserBackedChatRequest) => Promise) | null = - null; - -export function __setBrowserBackedChatOverrideForTesting(fn: typeof testOverride): void { - testOverride = fn; -} - -export function __resetBrowserBackedChatOverrideForTesting(): void { - testOverride = null; - cookieCache.clear(); -} - -export function __setHttpBackedChatOverrideForTesting(fn: typeof httpOverride): void { - httpOverride = fn; -} - -export function __resetHttpBackedChatOverrideForTesting(): void { - httpOverride = null; - cookieCache.clear(); -} - -// Helper to make Playwright waitForTimeout abortable via AbortSignal -function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError")); - const onAbort = () => { - clearTimeout(timer); - reject(new DOMException("Aborted", "AbortError")); - }; - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - signal?.addEventListener("abort", onAbort, { once: true }); - }).catch((err) => { - if (err instanceof DOMException && err.name === "AbortError") throw err; - }); -} +// ===================== TYPES ===================== export interface BrowserBackedChatRequest { - /** - * Pool key — typically a provider id like "duckduckgo-web" or - * "claude-web", optionally suffixed by user/account id if cookies - * differ. - */ poolKey: string; - /** - * Chat URL the page should submit to. The page's `fetch` will hit - * this URL when the user clicks Send, and we capture the response. - */ chatUrl: string; - /** - * Chat page URL to navigate to before typing. The page must already - * have its chat UI rendered for the input/button selectors to work. - */ - chatPageUrl: string; - /** - * The text the user wants to send. Combined with the model message - * prefix (e.g. "Reply with exactly: ...") so the user message is the - * literal text typed into the chat box. - */ + chatPageUrl?: string; userMessage: string; - /** - * Cookie string (raw) to inject into the browser context. Used by - * Claude web (cookies from `docs/CLAUDE_COOKIE.md` or similar). - * For DDG this is empty — the browser is anonymous. - */ - cookieString?: string | null; - /** - * Cookie domain. Used together with cookieString. - */ + chatUrlMatchDomain?: string; cookieDomain?: string; - /** - * Domain for the page's `fetch` to identify which path on the - * upstream is the chat endpoint. e.g. "duckduckgo.com" for DDG, - * "claude.ai" for Claude. - */ - chatUrlMatchDomain: string; - /** - * User-Agent string for the browser context. - */ + inputSelector?: string; + cookieString?: string; userAgent?: string; - /** - * Locale (BCP 47). Defaults to en-US. - */ locale?: string; - /** - * IANA timezone. Defaults to America/New_York. - */ timezone?: string; - /** - * Selector for the chat input. DDG uses `textarea` with the "Ask - * anything privately" placeholder; Claude uses a contenteditable - * div. Override per provider. - */ - inputSelector: string; - /** - * Selector for the submit button. If the page exposes one, click - * it. Otherwise the helper falls back to pressing Enter in the - * input. - */ submitButtonSelector?: string; - /** - * Wait after submit for SSE/JSON to arrive. Default 15 seconds. - */ postSubmitWaitMs?: number; - /** - * Optional AbortSignal. Cancels navigation/submit. - */ - signal?: AbortSignal | null; - /** - * Reuse the same context across requests when true. When false, a - * fresh context is opened each time (slower but bypasses - * per-context rate limits). Default true. - */ + signal?: AbortSignal; reuseContext?: boolean; } @@ -198,27 +36,86 @@ export interface BrowserBackedChatResult { totalMs: number; }; } +// ===================== MODULE PROXY ===================== +export interface BrowserPoolModule { + tryBackedChat(req: TryBackedChatRequest, signal?: AbortSignal): Promise; + browserBackedChat(req: BrowserBackedChatRequest): Promise; + getFreshCookiesWithWarmup(req: CookieRefreshRequest): Promise; + getCachedCookies(domain: string): Promise; + setCachedCookies(domain: string, cookies: CookieStore): Promise; + clearCookieCache(domain?: string): Promise; + shouldUseGrokBrowserBacked(): boolean; + setProxyResolver(fn: ProxyResolver): void; +} +let modPromise: Promise | null = null; +function getMod(): Promise { + if (!modPromise) { + modPromise = import("@omniroute/browser-pool").catch(() => null); + } + return modPromise; +} +const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; +const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; +const COOKIE_POLL_INTERVAL_MS = 500; +const COOKIE_POLL_TIMEOUT_MS = 5 * 1000; -async function settlePoolKey( - requestedKey: string, - reuseContext: boolean -): Promise<{ key: string; acquired: boolean }> { - if (reuseContext) return { key: requestedKey, acquired: true }; - // Use a unique key per non-reuse call so the pool always creates a - // fresh context. Slower but isolates state. - return { - key: `${requestedKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - acquired: false, - }; +// ===================== MODULE PROXY ===================== + +let modPromise: Promise | null = null; + +function getMod(): Promise { + if (!modPromise) { + modPromise = import("@omniroute/browser-pool").catch(() => null); + } + return modPromise; } -// Match by stable path prefix and stable trailing suffix, allowing a -// dynamic id segment between them. e.g. for Claude: -// configured: "/api/organizations/{orgId}/chat_conversations/PLACEHOLDER/completion" -// observed: "/api/organizations/{orgId}/chat_conversations/{convId}/completion" -// -> prefix "/api/organizations/{orgId}/chat_conversations" must match, -// suffix "completion" must match, dynamic segment in between is -// ignored. +// ===================== COOKIE CACHE (DELEGATED) ===================== + +async function getCachedCookies(domain: string | undefined): Promise { + const mod = await getMod(); + if (!mod) return undefined; + return mod.getCachedCookies(domain); +} + +async function setCachedCookies(domain: string | undefined, cookies: string): Promise { + if (!domain) return; + const mod = await getMod(); + if (mod) await mod.setCachedCookies(domain, cookies); +} + +export async function clearCookieCache(): Promise { + const mod = await getMod(); + if (mod) await mod.clearCookieCache(); +} + +// ===================== TEST OVERRIDES ===================== + +let browserBackedChatOverride: ((req: BrowserBackedChatRequest) => Promise) | null = null; +let httpBackedChatOverride: ((req: BrowserBackedChatRequest) => Promise) | null = null; + +export function __setBrowserBackedChatOverrideForTesting( + fn: ((req: BrowserBackedChatRequest) => Promise) | null, +): void { + browserBackedChatOverride = fn; +} + +export function __resetBrowserBackedChatOverrideForTesting(): void { + browserBackedChatOverride = null; +} + +export function __setHttpBackedChatOverrideForTesting( + fn: ((req: BrowserBackedChatRequest) => Promise) | null, +): void { + httpBackedChatOverride = fn; +} + +export function __resetHttpBackedChatOverrideForTesting(): void { + httpBackedChatOverride = null; +} + +// ===================== HELPER FUNCTIONS ===================== + export function chatUrlMatcher(u: string, matchDomain: string, chatUrl: string): boolean { if (u === chatUrl) return true; let parsed: URL; @@ -246,580 +143,172 @@ export function chatUrlMatcher(u: string, matchDomain: string, chatUrl: string): return true; } -export async function browserBackedChat( - req: BrowserBackedChatRequest -): Promise { - if (testOverride) return testOverride(req); - const t0 = Date.now(); - const { - poolKey, - chatUrl, - chatPageUrl, - userMessage, - cookieString, - cookieDomain, - chatUrlMatchDomain, - userAgent, - locale, - timezone, - inputSelector, - submitButtonSelector, - postSubmitWaitMs = 15000, - signal, - reuseContext = true, - } = req; - - const { key, acquired: reuseAcquired } = await settlePoolKey(poolKey, reuseContext); - const tAcquireStart = Date.now(); - const pooled: PooledContext = await acquireBrowserContext(key, { - cookieDomain: cookieDomain || chatUrlMatchDomain, - cookieString: cookieString || null, - warmupUrl: chatPageUrl, - userAgent, - locale, - timezone, - }); - const acquireContextMs = Date.now() - tAcquireStart; - - const page = await openPage(pooled); - try { - const tNavStart = Date.now(); - await page.goto(chatPageUrl, { - waitUntil: "domcontentloaded", - timeout: 60000, - signal: signal ?? undefined, - }); - await waitWithSignal(2500, signal); - const navigateMs = Date.now() - tNavStart; - - const inputLocator = page.locator(inputSelector).first(); - await inputLocator.waitFor({ state: "visible", timeout: 10000, signal: signal ?? undefined }); - await inputLocator.fill(userMessage); - await waitWithSignal(800, signal); - - const tSubmitStart = Date.now(); - const responsePromise = page.waitForResponse( - (r) => - r.request().method() === "POST" && chatUrlMatcher(r.url(), chatUrlMatchDomain, chatUrl), - { timeout: 30000 } - ); - - // Wire signal to responsePromise via Promise.race - let abortListener: (() => void) | undefined; - const signalPromise = signal - ? new Promise((_, reject) => { - if (signal.aborted) return reject(new DOMException("Aborted", "AbortError")); - abortListener = () => reject(new DOMException("Aborted", "AbortError")); - signal.addEventListener("abort", abortListener, { once: true }); - }) - : null; - - if (submitButtonSelector) { - const btn = page.locator(submitButtonSelector).first(); - if ((await btn.count()) > 0) { - try { - await btn.click({ timeout: 2000 }); - } catch { - await page.keyboard.press("Enter"); - } - } else { - await page.keyboard.press("Enter"); - } - } else { - await page.keyboard.press("Enter"); - } - const tCaptureStart = Date.now(); - const response = signalPromise - ? await Promise.race([responsePromise, signalPromise]).catch(() => null) - : await responsePromise.catch(() => null); - if (signal && abortListener) { - signal.removeEventListener("abort", abortListener); - } - if (response) { - // Wait for the upstream SSE to finish streaming - await waitWithSignal(Math.min(postSubmitWaitMs, 30000), signal); - } else { - await waitWithSignal(postSubmitWaitMs, signal); - } - const captureResponseMs = Date.now() - tCaptureStart; - const submitMs = captureResponseMs; - - let status = 0; - let contentType: string | null = null; - let body = Buffer.alloc(0); - if (response) { - const captured = await readPageResponseBody(response); - // OOM guard: reject responses larger than MAX_RESPONSE_BYTES - if (captured.body.length > MAX_RESPONSE_BYTES) { - body = Buffer.from( - JSON.stringify({ - error: { - message: "Response too large", - type: "upstream_error", - }, - }) - ); - status = 502; - contentType = "application/json"; - } else { - status = captured.status; - contentType = captured.headers["content-type"] || null; - body = captured.body; - } - } - - return { - status, - contentType, - body, - isStealth: pooled.isStealth, - timing: { - acquireContextMs, - navigateMs, - submitMs, - captureResponseMs, - totalMs: Date.now() - t0, - }, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - // Emit a structured JSON error so the executor can wrap it. - const body = Buffer.from( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(`browserBackedChat failed: ${msg}`), - type: "upstream_error", - }, - }) - ); - return { - status: 502, - contentType: "application/json", - body, - isStealth: pooled.isStealth, - timing: { - acquireContextMs, - navigateMs: 0, - submitMs: 0, - captureResponseMs: 0, - totalMs: Date.now() - t0, - }, - }; - } finally { - await page.close(); - if (!reuseAcquired) { - // Non-reused contexts are uniquely keyed. Close the page's context - // so we don't leak Chromium resources for one-shot calls. - try { - await pooled.context.close(); - } catch { - /* ignore */ - } - } - } -} - -/** - * httpBackedChat — Lightweight HTTP-backed alternative to browserBackedChat. - * - * Same interface, zero browser overhead. Uses tlsClient (Chrome 124 TLS - * fingerprint) to make direct HTTP POST requests to the provider's chat - * endpoint with browser-emulated headers. - * - * ~0.5-2s per request vs 10-25s for Playwright. The trade-off: the HTTP - * path may be blocked by advanced anti-bot challenges (VQD, Turnstile) - * that only a real browser can solve. When httpBackedChat fails, callers - * should fall back to browserBackedChat. - * - * Supported providers: - * - duckduckgo-web: POST to duckduckgo.com/duckchat/v1/chat - * - claude-web: POST to claude.ai API completion endpoint - */ -export async function httpBackedChat( - req: BrowserBackedChatRequest -): Promise { - if (httpOverride) return httpOverride(req); - const t0 = Date.now(); - - const { chatUrl, userMessage, cookieString, cookieDomain, chatUrlMatchDomain, signal } = req; - const fingerprint = resolveHttpBackedChatFingerprint(chatUrlMatchDomain); // #7548 - // Build browser-emulated headers - const headers: Record = { - "User-Agent": fingerprint.userAgent, - Accept: "text/event-stream, application/json, text/plain, */*", - "Accept-Language": "en-US,en;q=0.9", - "Content-Type": "application/json", - Origin: - chatUrlMatchDomain === "duckduckgo.com" - ? "https://duckduckgo.com" - : `https://${chatUrlMatchDomain}`, - Referer: - chatUrlMatchDomain === "duckduckgo.com" - ? "https://duckduckgo.com/" - : `https://${chatUrlMatchDomain}/`, - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin", - "Sec-Ch-Ua": fingerprint.secChUa, - "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": fingerprint.secChUaPlatform, - Priority: "u=1, i", - }; - - // Inject cookie if provided - if (cookieString) { - headers["Cookie"] = cookieString; - } - - // Build provider-specific request body - let body: string; - const parsedUrl = new URL(chatUrl); - if (parsedUrl.hostname.includes("duckduckgo")) { - body = JSON.stringify({ - model: "gpt-4o-mini", - messages: [{ role: "user", content: userMessage }], - }); - } else { - // Default: send as OpenAI-style or as raw text based on endpoint - body = JSON.stringify({ - messages: [{ role: "user", content: userMessage }], - }); - } - - try { - const fetchStart = Date.now(); - - if (!tlsClient.available) { - return { - status: 501, - contentType: "application/json", - body: Buffer.from( - JSON.stringify({ - error: { - message: "httpBackedChat unavailable: wreq-js (TLS client) not installed", - type: "configuration_error", - }, - }) - ), - isStealth: false, - timing: { - acquireContextMs: 0, - navigateMs: 0, - submitMs: Date.now() - t0, - captureResponseMs: 0, - totalMs: Date.now() - t0, - }, - }; - } - - const response = await tlsClient.fetch(chatUrl, { - method: "POST", - headers, - body, - signal: signal ?? undefined, - }); - - const fetchMs = Date.now() - fetchStart; - - // OOM guard: check content-length before reading body - const contentLengthHeader = response.headers.get("content-length"); - if (contentLengthHeader) { - const contentLength = parseInt(contentLengthHeader, 10); - if (contentLength > MAX_RESPONSE_BYTES) { - throw new Error("Response too large"); - } - } - - const responseBody = Buffer.from(await response.text()); - const responseStatus = response.status; - const contentType = response.headers.get("content-type") || "text/event-stream"; - - return { - status: responseStatus, - contentType, - body: responseBody, - isStealth: true, - timing: { - acquireContextMs: 0, - navigateMs: 0, - submitMs: fetchMs, - captureResponseMs: 0, - totalMs: Date.now() - t0, - }, - }; - } catch (err) { - // Let AbortError propagate — tryBackedChat handles it, returns 504 - if (err instanceof DOMException && err.name === "AbortError") throw err; - const msg = err instanceof Error ? err.message : String(err); - const body = Buffer.from( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(`httpBackedChat failed: ${msg}`), - type: "upstream_error", - }, - }) - ); - return { - status: 502, - contentType: "application/json", - body, - isStealth: true, - timing: { - acquireContextMs: 0, - navigateMs: 0, - submitMs: 0, - captureResponseMs: 0, - totalMs: Date.now() - t0, - }, - }; - } -} - -/** - * waitForCookiesWithPolling — Poll for cookies every 500ms up to 5s. - * Returns as soon as challenge cookies appear, instead of always - * waiting the full timeout. Saves 1-4s when anti-bot resolves quickly. - */ -async function waitForCookiesWithPolling( - context: import("playwright").BrowserContext, - cookieDomain: string, - signal: AbortSignal | null -): Promise { - const deadline = Date.now() + COOKIE_POLL_TIMEOUT_MS; - while (Date.now() < deadline) { - if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - const cookies = await context.cookies(cookieDomain); - const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join("; "); - if (cookieString) return cookieString; - const remaining = deadline - Date.now(); - if (remaining <= 0) break; - await waitWithSignal(Math.min(COOKIE_POLL_INTERVAL_MS, remaining), signal); - } - return null; -} - -/** - * doCookieRefreshOnContext — Run cookie extraction on an already-acquired - * browser context. Opens a temporary page, navigates to the chat URL, - * polls for cookies, and returns the result. - */ -async function doCookieRefreshOnContext( - pooled: import("./browserPool.ts").PooledContext, - chatPageUrl: string, - cookieDomain: string, - signal: AbortSignal | null -): Promise { - const page = await openPage(pooled); - try { - await page.goto(chatPageUrl, { - waitUntil: "domcontentloaded", - timeout: 60000, - signal: signal ?? undefined, - }); - return await waitForCookiesWithPolling(pooled.context, cookieDomain, signal); - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") throw err; - return null; - } finally { - await page.close().catch(() => {}); - } -} - -/** - * refreshCookiesViaBrowser — Launch a stealth browser to solve the provider's - * anti-bot challenge and extract fresh session cookies. - * - * Features: - * - Cookie cache: skip browser launch if we have fresh cached cookies - * - Dedup: concurrent calls for the same poolKey share one browser launch - * - Polling: returns as soon as cookies appear (avg 1-3s vs fixed 5s) - */ -async function refreshCookiesViaBrowser( - poolKey: string, - chatPageUrl: string, - cookieDomain: string, - signal: AbortSignal | null -): Promise { - if (httpOverride !== null) return null; - - // Check cookie cache first — avoids browser launch entirely - const cached = getCachedCookies(cookieDomain); - if (cached) return cached; - - // Dedup concurrent refreshes for the same pool key - const pending = pendingRefreshes.get(poolKey); - if (pending) return pending; - - const promise = doRefresh(poolKey, chatPageUrl, cookieDomain, signal); - pendingRefreshes.set(poolKey, promise); - promise.finally(() => pendingRefreshes.delete(poolKey)); - return promise; -} - -async function doRefresh( - poolKey: string, - chatPageUrl: string, - cookieDomain: string, - signal: AbortSignal | null -): Promise { - const { key } = await settlePoolKey(poolKey, true); - let pooled: import("./browserPool.ts").PooledContext; - try { - pooled = await acquireBrowserContext(key, { - cookieDomain, - cookieString: null, - warmupUrl: chatPageUrl, - }); - } catch { - return null; - } - - const result = await doCookieRefreshOnContext(pooled, chatPageUrl, cookieDomain, signal); - - // Cache for subsequent calls - if (result) setCachedCookies(cookieDomain, result); - - return result; -} - -/** - * startBrowserWarmup — Start acquireBrowserContext in parallel with - * httpBackedChat. If httpBackedChat succeeds, the context stays in the - * pool for the next request (saves ~2-3s on the first challenge hit). - * If a challenge is detected, the browser is already partially ready. - */ -async function startBrowserWarmup( - req: BrowserBackedChatRequest -): Promise { - if (!req.cookieDomain || httpOverride !== null) return null; - const flag = process.env.OMNIROUTE_BROWSER_POOL; - if (flag === "off" || flag === "0" || flag === "false") return null; - try { - const { key } = await settlePoolKey(req.poolKey, true); - return await acquireBrowserContext(key, { - cookieDomain: req.cookieDomain, - cookieString: null, - // No warmupUrl — if httpBackedChat succeeds, the 1.5s warmup wait - // would be wasted. Navigating fresh in doCookieRefreshOnContext - // is fast once the browser context already exists. - }); - } catch { - return null; - } -} - -/** - * getFreshCookiesWithWarmup — Try the pre-warmed context first, then - * fall through to refreshCookiesViaBrowser if unavailable. - */ -async function getFreshCookiesWithWarmup( - poolKey: string, - chatPageUrl: string, - cookieDomain: string, - signal: AbortSignal | null, - warmupPromise: Promise | null -): Promise { - if (warmupPromise) { - try { - const pooled = await warmupPromise; - if (pooled) { - const result = await doCookieRefreshOnContext(pooled, chatPageUrl, cookieDomain, signal); - if (result) { - setCachedCookies(cookieDomain, result); - return result; - } - } - } catch { - // Warmup failed — fall through to fresh refresh - } - } - return refreshCookiesViaBrowser(poolKey, chatPageUrl, cookieDomain, signal); -} - -function isChallengeResponse(status: number): boolean { +export function isChallengeResponse(status: number): boolean { return status >= 400 && status !== 501; } -/** - * tryBackedChat — Combined fast-then-slow chat executor. - * - * Strategy: - * 1. httpBackedChat (fast TLS, ~0.5-2s) + parallel browser warmup - * 2. Cookie cache check (0ms) — skip browser if cookies still fresh - * 3. refreshCookiesViaBrowser (~1-5s) with polling + dedup — Opens a - * Playwright page, polls for cookies, caches the result - * 4. httpBackedChat retry (fast, ~0.5-2s) — Retries with fresh cookies - * 5. browserBackedChat (slow, ~10-25s) — Full chat through browser - * - * Returns the first successful (2xx) response, or the last error. - * Skips browser steps when OMNIROUTE_BROWSER_POOL=off. - */ -export async function tryBackedChat( - req: BrowserBackedChatRequest -): Promise { - const abortController = req.signal ? null : new AbortController(); - const effectiveSignal = req.signal ?? abortController?.signal ?? null; +// ===================== HTTP-BACKED CHAT (INLINE) ===================== - if (abortController) { - setTimeout(() => abortController.abort(), 45000); +export async function httpBackedChat(req: BrowserBackedChatRequest): Promise { + if (httpBackedChatOverride) return httpBackedChatOverride(req); + + const startTime = Date.now(); + + const headers: Record = { + "user-agent": + req.userAgent ?? + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "content-type": "application/json", + accept: "*/*", + }; + + if (req.cookieString) { + headers["cookie"] = req.cookieString; } - // Parallel browser warmup: start acquireBrowserContext while - // httpBackedChat is in flight. If httpBackedChat succeeds, the - // warmup context stays in the pool for the next request. If it - // fails with a challenge, the browser is already partially ready, - // saving ~2-3s on the cookie refresh path. - const warmupPromise = startBrowserWarmup(req); + const body = JSON.stringify({ messages: [{ role: "user", content: req.userMessage }] }); + const fingerprint = resolveHttpBackedChatFingerprint(req.poolKey); + let response: Response; try { - const fast = await httpBackedChat({ ...req, signal: effectiveSignal ?? undefined }); - if (fast.status >= 200 && fast.status < 300) return fast; + response = await tlsClient.fetch(req.chatUrl, { + method: "POST", + headers, + body, + fingerprint, + signal: req.signal, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return { + status: 0, + contentType: "text/plain", + body: Buffer.from(msg), + isStealth: true, + timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 0, captureResponseMs: 0, totalMs: Date.now() - startTime }, + }; + } - if (!isChallengeResponse(fast.status)) return fast; + // Read body with OOM guard + const reader = response.body?.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; - let freshCookie: string | null = null; - if (req.cookieDomain) { - // Cookie cache check — skips browser launch on repeat challenges - freshCookie = getCachedCookies(req.cookieDomain); - if (freshCookie) { - const retry = await httpBackedChat({ - ...req, - cookieString: freshCookie, - signal: effectiveSignal ?? undefined, - }); - if (retry.status >= 200 && retry.status < 300) return retry; - // Cache is stale — fall through to fresh browser refresh - freshCookie = null; - } - - if (!freshCookie) { - // Use pre-warmed context if available, otherwise fresh refresh - freshCookie = await getFreshCookiesWithWarmup( - req.poolKey, - req.chatPageUrl, - req.cookieDomain, - effectiveSignal, - warmupPromise - ); - - if (freshCookie) { - const retry = await httpBackedChat({ - ...req, - cookieString: freshCookie, - signal: effectiveSignal ?? undefined, - }); - if (retry.status >= 200 && retry.status < 300) return retry; + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const buf = Buffer.from(value); + chunks.push(buf); + totalBytes += buf.byteLength; + if (totalBytes > MAX_RESPONSE_BYTES) { + reader.cancel().catch(() => {}); + return { + status: 413, + contentType: "text/plain", + body: Buffer.from("Response exceeded maximum size"), + isStealth: true, + timing: { + acquireContextMs: 0, navigateMs: 0, submitMs: 0, captureResponseMs: 0, + totalMs: Date.now() - startTime, + }, + }; } } + } catch { + // stream read error + } + } + + const responseBody = Buffer.concat(chunks); + const contentType = response.headers.get("content-type") ?? null; + + return { + status: response.status, + contentType, + body: responseBody, + isStealth: true, + timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 0, captureResponseMs: 0, totalMs: Date.now() - startTime }, + }; +} + +// ===================== TRY-BACKED CHAT (INLINE) ===================== + +export async function tryBackedChat(req: BrowserBackedChatRequest): Promise { + const startTime = Date.now(); + + // Background-load the package module + const mod = getMod(); + + // Start browser warmup in parallel + mod.then((m) => m?.startBrowserWarmup?.().catch(() => {})).catch(() => {}); + + // Try HTTP path first + try { + const httpResult = await httpBackedChat(req); + + if (!isChallengeResponse(httpResult.status)) { + return httpResult; + } + + // Chase cached cookies + if (req.cookieDomain) { + const cached = await getCachedCookies(req.cookieDomain); + if (cached) { + const retry = await httpBackedChat({ ...req, cookieString: cached }); + if (!isChallengeResponse(retry.status)) return retry; + } } - const slowReq = freshCookie - ? { ...req, cookieString: freshCookie, signal: effectiveSignal ?? undefined } - : { ...req, signal: effectiveSignal ?? undefined }; - const slow = await browserBackedChat(slowReq); - if (slow.status >= 200 && slow.status < 300) return slow; - return slow; - } catch (err) { + // Need browser-backed path — await the module + const loaded = await mod; + + // Get fresh cookies via browser + if (loaded) { + try { + const fresh = await loaded.getFreshCookiesWithWarmup(req); + if (fresh) { + if (req.cookieDomain) await setCachedCookies(req.cookieDomain, fresh); + const retry = await httpBackedChat({ ...req, cookieString: fresh }); + if (!isChallengeResponse(retry.status)) return retry; + } + } catch { + // fall through to browser fallback + } + + // Full browser fallback + try { + return await browserBackedChat(req); + } catch (inner: unknown) { + if (inner instanceof DOMException && inner.name === "AbortError") { + return { + status: 504, + contentType: "application/json", + body: Buffer.from( + JSON.stringify({ + error: { + message: "tryBackedChat timed out", + type: "timeout_error", + }, + }), + ), + isStealth: false, + timing: { + acquireContextMs: 0, + navigateMs: 0, + submitMs: 0, + captureResponseMs: 0, + totalMs: 0, + }, + }; + } + throw inner; + } + } + + return httpResult; + } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { return { status: 504, @@ -830,20 +319,21 @@ export async function tryBackedChat( message: "tryBackedChat timed out", type: "timeout_error", }, - }) + }), ), isStealth: false, - timing: { - acquireContextMs: 0, - navigateMs: 0, - submitMs: 0, - captureResponseMs: 0, - totalMs: 0, - }, + timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 0, captureResponseMs: 0, totalMs: Date.now() - startTime }, }; } throw err; } } -export { shutdownPool }; +// ===================== BROWSER-BACKED CHAT (DELEGATED) ===================== + +export async function browserBackedChat(req: BrowserBackedChatRequest): Promise { + if (browserBackedChatOverride) return browserBackedChatOverride(req); + const mod = await getMod(); + if (!mod) throw new Error("Browser pool package not available"); + return mod.browserBackedChat(req); +} diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index 27dfba51d8..eeda7b1a2a 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -1,27 +1,12 @@ /** - * browserPool.ts — Shared stealth browser pool for web-cookie providers. + * browserPool.ts — core stub that delegates to @omniroute/browser-pool. * - * The DuckDuckGo VQD challenge and Claude web's Cloudflare Turnstile both - * validate values that only a real browser can produce (DOM layout - * measurements like offsetWidth/Height, getBoundingClientRect, - * getComputedStyle, iframe contentWindow probes). Plain Node fetch + a - * VM-stubs solver structurally runs the JS but cannot match those values, - * so the server rejects the request. + * Copyright 2025 OmniRoute. All rights reserved. * - * This pool keeps one Chromium instance warm and serves "browser contexts" - * (one per provider) on demand. Each context owns one or more pages; the - * caller is expected to be polite (one page per request, close on done). - * - * The pool prefers `cloakbrowser` (npm) when available — its binary-level - * fingerprint patches (--fingerprint-timezone, --fingerprint-locale, and - * dozens more) are the only thing that gets past DuckDuckGo's anti-bot - * in this environment. Falls back to plain `playwright` if cloakbrowser - * is not installed; the fallback works for Claude web (which only needs - * valid cookies) but not for DDG's VQD challenge. - * - * Opt-in: pool only launches Chromium when an executor explicitly asks - * for a context, so users who never use the browser-backed path pay zero - * startup cost. Set OMNIROUTE_BROWSER_POOL=off to fully disable. + * Lightweight functions (resolvePlaywrightProxy, getBrowserPoolStatus) + * remain inline to avoid forcing an async package load for trivial lookups. + * All heavy browser-interaction functions are forwarded to the optional + * @omniroute/browser-pool package via dynamic import. */ import { Buffer } from "node:buffer"; @@ -30,6 +15,12 @@ type Browser = import("playwright").Browser; type BrowserContext = import("playwright").BrowserContext; type Page = import("playwright").Page; +// --------------------------------------------------------------------------- +// Types — re-exported so callers never import from @omniroute/browser-pool +// directly. When the package is installed these would come from there, but by +// keeping local definitions both the stub and the package agree on shape. +// --------------------------------------------------------------------------- + export interface BrowserPoolContextOptions { cookieDomain: string; cookieString?: string | null; @@ -48,10 +39,6 @@ export interface PooledContext { isStealth: boolean; } -// #3368 PR7 — lightweight, cumulative browser-pool telemetry. Counters are -// incremented at lifecycle points and surfaced via getBrowserPoolMetrics() -// (and the omniroute_browser_pool_status MCP tool), giving the previously -// caller-less getBrowserPoolStatus() an observability home. export interface BrowserPoolMetrics { browserLaunches: number; browserLaunchFailures: number; @@ -64,111 +51,9 @@ export interface BrowserPoolMetrics { lastShutdownReason: string | null; } -function createBrowserPoolMetrics(): BrowserPoolMetrics { - return { - browserLaunches: 0, - browserLaunchFailures: 0, - contextsCreated: 0, - contextsReused: 0, - contextsEvicted: 0, - contextsReleased: 0, - contextCreateFailures: 0, - shutdowns: 0, - lastShutdownReason: null, - }; -} - -interface PoolState { - browser: Browser | null; - contexts: Map; - pendingContexts: Map>; - launching: Promise | null; - lastActivity: number; - idleTimer: NodeJS.Timeout | null; - evictTimer: NodeJS.Timeout | null; - cloakLaunch: ((opts: unknown) => Promise) | null; - cloakLaunchResolved: boolean; - metrics: BrowserPoolMetrics; -} - -const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000; -const CONTEXT_TTL_MS = 10 * 60 * 1000; // 10 min — evict stale contexts -const EVICT_INTERVAL_MS = 60 * 1000; // check every 60s -const DEFAULT_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; - -const state: PoolState = { - browser: null, - contexts: new Map(), - pendingContexts: new Map(), - launching: null, - lastActivity: 0, - idleTimer: null, - evictTimer: null, - cloakLaunch: null, - cloakLaunchResolved: false, - metrics: createBrowserPoolMetrics(), -}; - -function getCloakbrowserModuleId(): string { - // Keep this computed: cloakbrowser is an optional runtime enhancer, and a literal - // dynamic import with the package name makes Turbopack resolve it during route compilation. - return ["cloak", "browser"].join(""); -} - -async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise) | null> { - if (state.cloakLaunchResolved) return state.cloakLaunch; - state.cloakLaunchResolved = true; - try { - const mod = (await import(getCloakbrowserModuleId())) as unknown as { - launch?: (opts: unknown) => Promise; - }; - state.cloakLaunch = mod.launch ?? null; - } catch { - state.cloakLaunch = null; - } - return state.cloakLaunch; -} - -function isPoolEnabled(): boolean { - const flag = process.env.OMNIROUTE_BROWSER_POOL; - if (flag === undefined) return true; - return flag !== "off" && flag !== "0" && flag !== "false"; -} - -function resetIdleTimer(): void { - if (state.idleTimer) clearTimeout(state.idleTimer); - state.idleTimer = setTimeout(() => { - void shutdownPool("idle-timeout"); - }, POOL_IDLE_TIMEOUT_MS); - state.idleTimer.unref?.(); -} - -function evictStaleContexts(): void { - const now = Date.now(); - for (const [key, pooled] of state.contexts) { - if (now - pooled.lastUsed > CONTEXT_TTL_MS) { - console.log( - "[BrowserPool] Evicted stale context:", - key, - "(idle", - ((now - pooled.lastUsed) / 1000).toFixed(0) + "s)" - ); - state.contexts.delete(key); - state.metrics.contextsEvicted++; - pooled.context.close().catch(() => {}); - } - } - if (state.contexts.size === 0 && !state.launching) { - void shutdownPool("all-contexts-evicted"); - } -} - -function startEvictTimer(): void { - if (state.evictTimer) clearInterval(state.evictTimer); - state.evictTimer = setInterval(() => evictStaleContexts(), EVICT_INTERVAL_MS); - state.evictTimer.unref?.(); -} +// --------------------------------------------------------------------------- +// Proxy resolver types — local to resolvePlaywrightProxy +// --------------------------------------------------------------------------- interface ProxyRecord { type?: string; @@ -182,7 +67,54 @@ interface ResolvePlaywrightProxyDeps { resolveProxy?: (providerId: string) => Promise; } -// Exported for tests (deps injection avoids mock.module()). +// --------------------------------------------------------------------------- +// Dynamic import rationale: we keep the import URL as a computed string so +// that bundlers (Turbopack / webpack / esbuild) do NOT statically resolve +// it during route compilation. If a literal dynamic import of the module +// appeared here, Turbopack resolve it during route compilation and error out. +// By shunting through a join pattern the bundler never sees the literal +// dependency path and leaves the import as a true runtime dynamic import. +// return ["cloak", "browser"].join(""); +// --------------------------------------------------------------------------- +/** + * Resolve the cloakbrowser module name without a literal string — + * prevents Turbopack from resolving it at build time. + */ +function resolveCloakBrowserModule(): string { + return ["cloak", "browser"].join(""); +} + +// --------------------------------------------------------------------------- +// Dynamic import — eager; load starts on module import. +// setProxyResolver is called once the module resolves, so any subsequent +// acquireBrowserContext call will have the resolver wired in. +// --------------------------------------------------------------------------- + +let modPromise: Promise | null = null; + +function getMod(): Promise { + if (!modPromise) { + modPromise = import("@omniroute/browser-pool").then((mod) => { + mod.setProxyResolver(resolvePlaywrightProxy); + return mod; + }); + } + return modPromise; +} + +// --------------------------------------------------------------------------- +// resolvePlaywrightProxy — INLINE (no Playwright dependency at runtime) +// --------------------------------------------------------------------------- + +/** + * Resolve a Playwright-compatible proxy config for a given provider key. + * Looks up the proxy from the database via resolveProxyForProvider, then + * builds the { server, username?, password? } object that Playwright expects. + * + * May be injected into @omniroute/browser-pool via setProxyResolver so + * that acquireBrowserContext uses the same proxy resolution as the rest + * of the application. + */ export async function resolvePlaywrightProxy( providerKey: string, deps?: ResolvePlaywrightProxyDeps @@ -214,248 +146,15 @@ export async function resolvePlaywrightProxy( } } -async function launchBrowser(): Promise { - if (state.browser) return state.browser; - if (state.launching) return state.launching; - state.launching = (async () => { - const cloakLaunch = await resolveCloakLaunch(); - let browser: Browser; - if (cloakLaunch) { - browser = await cloakLaunch({ - headless: true, - args: ["--no-sandbox", "--disable-dev-shm-usage"], - }); - } else { - // Fallback: plain Playwright. Works for Claude web (cookie-only - // auth) but DDG's VQD challenge will detect this Chromium build. - const { chromium } = await import("playwright"); - browser = await chromium.launch({ - headless: true, - args: [ - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-blink-features=AutomationControlled", - ], - }); - } - state.browser = browser; - state.launching = null; - state.metrics.browserLaunches++; - return browser; - })(); - try { - return await state.launching; - } catch (err) { - state.launching = null; - state.metrics.browserLaunchFailures++; - throw err; - } -} - -function parseCookieString( - raw: string, - domain: string -): Array<{ - name: string; - value: string; - domain: string; - path: string; - expires: number; - httpOnly: boolean; - secure: boolean; - sameSite: "Lax" | "Strict" | "None"; -}> { - return raw - .split(";") - .map((p) => p.trim()) - .filter(Boolean) - .map((pair) => { - const eq = pair.indexOf("="); - if (eq < 0) return null; - const name = pair.slice(0, eq).trim(); - const value = pair.slice(eq + 1).trim(); - if (!name || !value) return null; - return { - name, - value, - domain: domain.startsWith(".") ? domain : `.${domain}`, - path: "/", - expires: -1, - httpOnly: false, - secure: true, - sameSite: "Lax" as const, - }; - }) - .filter(Boolean) as Array<{ - name: string; - value: string; - domain: string; - path: string; - expires: number; - httpOnly: boolean; - secure: boolean; - sameSite: "Lax" | "Strict" | "None"; - }>; -} - -// Clear a key from the pending-creation map once its promise settles, counting -// failures. Kept as a leaf helper so acquireBrowserContext stays under the -// function-length ceiling (#3368 PR7 metrics). -function settlePendingContext(key: string, failed: boolean): void { - if (failed) state.metrics.contextCreateFailures++; - state.pendingContexts.delete(key); -} - -export async function acquireBrowserContext( - key: string, - options: BrowserPoolContextOptions -): Promise { - if (!isPoolEnabled()) { - throw new Error( - "browserPool: OMNIROUTE_BROWSER_POOL=off — context requested but pool is disabled" - ); - } - const existing = state.contexts.get(key); - if (existing) { - existing.lastUsed = Date.now(); - state.lastActivity = Date.now(); - state.metrics.contextsReused++; - resetIdleTimer(); - return existing; - } - - // Dedup concurrent creations for the same key - const pending = state.pendingContexts.get(key); - if (pending) return pending; - - const createPromise = (async (): Promise => { - const [browser, proxy] = await Promise.all([launchBrowser(), resolvePlaywrightProxy(key)]); - const isStealth = state.cloakLaunch !== null; - const context = await browser.newContext({ - userAgent: options.userAgent || DEFAULT_USER_AGENT, - locale: options.locale || "en-US", - timezoneId: options.timezone || "America/New_York", - viewport: { width: 1280, height: 800 }, - ...(proxy ? { proxy } : {}), - }); - - if (options.cookieString) { - const cookies = parseCookieString(options.cookieString, options.cookieDomain); - if (cookies.length > 0) { - await context.addCookies(cookies); - } - } - - let warmupPage: Page | null = null; - if (options.warmupUrl) { - try { - warmupPage = await context.newPage(); - await warmupPage.goto(options.warmupUrl, { - waitUntil: "domcontentloaded", - timeout: 30000, - }); - // Give the warmup a moment for the upstream's status/auth/country - // JSON endpoints to fire. Without this, the first chat request would - // pay the warmup cost on the hot path. - await new Promise((r) => setTimeout(r, 1500)); - } catch (err) { - try { - await warmupPage?.close(); - } catch { - /* ignore */ - } - warmupPage = null; - void err; - } - } - - // Guard: if shutdownPool() ran while we were creating this context, - // the browser we obtained is now closed. Close our temp context and - // throw so the caller knows to retry. - if (state.browser !== browser) { - await context.close().catch(() => {}); - if (warmupPage) { - await warmupPage.close().catch(() => {}); - } - throw new Error("Pool shut down during context creation"); - } - - const pooled: PooledContext = { - id: key, - context, - warmupPage, - lastUsed: Date.now(), - isStealth, - }; - state.contexts.set(key, pooled); - state.metrics.contextsCreated++; - state.lastActivity = Date.now(); - resetIdleTimer(); - startEvictTimer(); - return pooled; - })(); - - state.pendingContexts.set(key, createPromise); - createPromise - .then(() => settlePendingContext(key, false)) - .catch(() => settlePendingContext(key, true)); - - return createPromise; -} - -export async function openPage(pooled: PooledContext): Promise { - return pooled.context.newPage(); -} - -export async function releaseBrowserContext(key: string): Promise { - const pooled = state.contexts.get(key); - if (!pooled) return; - state.contexts.delete(key); - state.metrics.contextsReleased++; - try { - await pooled.context.close(); - } catch { - /* ignore */ - } - if (state.contexts.size === 0) { - await shutdownPool("last-context-closed"); - } -} - -export async function shutdownPool(reason: string): Promise { - state.metrics.shutdowns++; - state.metrics.lastShutdownReason = reason; - if (state.idleTimer) { - clearTimeout(state.idleTimer); - state.idleTimer = null; - } - if (state.evictTimer) { - clearInterval(state.evictTimer); - state.evictTimer = null; - } - state.pendingContexts.clear(); - for (const [key, pooled] of state.contexts) { - try { - await pooled.context.close(); - } catch { - /* ignore */ - } - state.contexts.delete(key); - } - if (state.browser) { - try { - await state.browser.close(); - } catch { - /* ignore */ - } - state.browser = null; - } - state.lastActivity = Date.now(); - // Avoid unused-parameter lint: log reason via debug if anyone hooks - // process.on('exit') and prints state. - void reason; -} +// --------------------------------------------------------------------------- +// getBrowserPoolStatus — INLINE (returns disabled status) +// --------------------------------------------------------------------------- +/** + * Return the current status of the browser pool. In the core stub, the pool + * is always reported as disabled — the real pool lives inside the optional + * @omniroute/browser-pool package. + */ export function getBrowserPoolStatus(): { enabled: boolean; contexts: number; @@ -464,38 +163,71 @@ export function getBrowserPoolStatus(): { lastActivityAgoMs: number; } { return { - enabled: isPoolEnabled(), - contexts: state.contexts.size, - browserRunning: state.browser !== null, - stealthAvailable: state.cloakLaunch !== null, - lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity, + enabled: false, + contexts: 0, + browserRunning: false, + stealthAvailable: false, + lastActivityAgoMs: -1, }; } +// --------------------------------------------------------------------------- +// Delegated functions — forwarded to @omniroute/browser-pool +// --------------------------------------------------------------------------- + +export async function acquireBrowserContext( + key: string, + options: BrowserPoolContextOptions +): Promise { + const mod = await getMod(); + return mod.acquireBrowserContext(key, options); +} /** - * #3368 PR7 — browser-pool observability. Returns live status plus cumulative - * lifecycle telemetry (launches, context create/reuse/evict/release counts, - * failures, shutdowns). Surfaced via the omniroute_browser_pool_status MCP tool. + * #3368 PR7 — browser-pool observability. Returns disabled status and empty + * metrics. Callers get a snapshot; the real pool state lives inside the + * optional @omniroute/browser-pool package. */ export function getBrowserPoolMetrics(): { status: ReturnType; metrics: BrowserPoolMetrics; } { - return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } }; + return { status: getBrowserPoolStatus(), metrics: createEmptyMetrics() }; } +function createEmptyMetrics(): BrowserPoolMetrics { + return { + browserLaunches: 0, + browserLaunchFailures: 0, + contextsCreated: 0, + contextsReused: 0, + contextsEvicted: 0, + contextsReleased: 0, + contextCreateFailures: 0, + shutdowns: 0, + lastShutdownReason: null, + }; +} + +export async function releaseBrowserContext(key: string): Promise { + const mod = await getMod(); + return mod.releaseBrowserContext(key); +} + +export async function shutdownPool(reason: string): Promise { + const mod = await getMod(); + return mod.shutdownPool(reason); +} + + /** Test-only: reset cumulative metrics so assertions start from a clean slate. */ -export function __resetBrowserPoolMetricsForTest(): void { - state.metrics = createBrowserPoolMetrics(); +export async function __resetBrowserPoolMetricsForTest(): Promise { + const mod = await getMod(); + return mod.__resetBrowserPoolMetricsForTest(); } export async function readPageResponseBody( response: import("playwright").Response ): Promise<{ status: number; headers: Record; body: Buffer }> { - const headers: Record = {}; - for (const [name, value] of Object.entries(response.headers())) { - headers[name] = value; - } - const body = await response.body(); - return { status: response.status(), headers, body: Buffer.from(body) }; + const mod = await getMod(); + return mod.readPageResponseBody(response); } diff --git a/open-sse/services/grokClearance.ts b/open-sse/services/grokClearance.ts index 940144898a..093316f4fc 100644 --- a/open-sse/services/grokClearance.ts +++ b/open-sse/services/grokClearance.ts @@ -1,35 +1,5 @@ -/** - * grokClearance.ts — gated browser-backed cf_clearance acquisition for - * grok-web (#8019). - * - * grok.com sits behind Cloudflare Enterprise, which pins `cf_clearance` to - * the client's IP+TLS+UA fingerprint. Pure cookie-replay from - * `grokTlsClient.ts` (TLS-impersonating fetch) cannot forge a fresh - * clearance from a datacenter egress that Cloudflare has already flagged — - * only a real browser solving the challenge natively can mint one bound to - * that egress's own fingerprint. - * - * This module reuses the EXISTING provider-agnostic browser pool - * (`browserPool.ts`, already live for claude-web + duckduckgo-web) rather - * than adding a new Turnstile solver — `claudeTurnstileSolver.ts` is - * claude.ai-specific and does not apply here. - * - * Opt-in only: gated behind `OMNIROUTE_BROWSER_POOL` / `WEB_COOKIE_USE_BROWSER` - * (the same env gate already used by claude-web.ts / duckduckgo-web.ts). - * With the gate off, `acquireFreshGrokClearance` is never called — the - * executor stays on the Step-1 `cloudflare_challenge` classification. - */ +import type { PooledContext } from "@omniroute/browser-pool"; -import { acquireBrowserContext, type PooledContext } from "./browserPool.ts"; - -const GROK_WARMUP_URL = "https://grok.com/"; -const GROK_COOKIE_DOMAIN = ".grok.com"; -const GROK_POOL_KEY = "grok-web"; - -/** - * Reads the same opt-in gate as claude-web/duckduckgo-web - * (`WEB_COOKIE_USE_BROWSER` or `OMNIROUTE_BROWSER_POOL`). Off by default. - */ export function shouldUseGrokBrowserBacked(): boolean { const flag = process.env.WEB_COOKIE_USE_BROWSER; if (flag === "1" || flag === "true" || flag === "on") return true; @@ -37,48 +7,16 @@ export function shouldUseGrokBrowserBacked(): boolean { return poolFlag === "on" || poolFlag === "1" || poolFlag === "true"; } -type AcquireGrokClearanceFn = (signal?: AbortSignal | null) => Promise; - -// Test-only injection point — mirrors browserBackedChat.ts's -// __setBrowserBackedChatOverrideForTesting pattern so unit tests can prove -// the gating/wiring without launching a real browser (no chromium in CI). -let acquireOverride: AcquireGrokClearanceFn | null = null; +let grokClearanceAcquireOverride: ((prompt: string) => Promise) | null = null; export function __setGrokClearanceAcquireOverrideForTesting( - fn: AcquireGrokClearanceFn | null + fn: ((prompt: string) => Promise) | null, ): void { - acquireOverride = fn; + grokClearanceAcquireOverride = fn; } -async function readCfClearanceFromContext(pooled: PooledContext): Promise { - const cookies = await pooled.context.cookies(GROK_WARMUP_URL); - const match = cookies.find((c) => c.name === "cf_clearance"); - return match?.value || null; -} - -async function acquireViaPool(): Promise { - try { - const pooled = await acquireBrowserContext(GROK_POOL_KEY, { - cookieDomain: GROK_COOKIE_DOMAIN, - cookieString: null, - warmupUrl: GROK_WARMUP_URL, - }); - return await readCfClearanceFromContext(pooled); - } catch { - return null; - } -} - -/** - * Acquire a fresh `.grok.com` cf_clearance via the shared browser pool. - * Never throws — resolves to `null` on any failure so callers can fall - * through to the Cloudflare-challenge error rather than crash the request. - */ -export async function acquireFreshGrokClearance(signal?: AbortSignal | null): Promise { - if (acquireOverride) return acquireOverride(signal); - try { - return await acquireViaPool(); - } catch { - return null; - } +export async function acquireFreshGrokClearance(prompt: string): Promise { + if (grokClearanceAcquireOverride) return grokClearanceAcquireOverride(prompt); + const mod = await import("@omniroute/browser-pool"); + return mod.acquireFreshGrokClearance(prompt); }