Files
OmniRoute/open-sse/services/browserBackedChat.ts
backryun bd472200d5 [v3.8.50] Fix Z.ai web browser transport and model capabilities (#8451)
* fix: complete Z.ai web browser transport

* refactor: address Z.ai review feedback

* test(zai-web): reconcile the #8014 endpoint guard with the chats/new + signed flow

Rebasing onto release/v3.8.49 pulled in #8503, which repointed CHAT_URL to
/api/v2/chat/completions and added an endpoint probe. This branch already
targets v2, so the executor conflict resolved to this branch's superset
(NEW_CHAT_URL + signature constants alongside the same v2 CHAT_URL). The two
tests needed adapting, because #8503's assertions assume the pre-rework flow:

- executor-zai-web.test.ts: the completion URL now carries the request
  signature as a query string, so an exact-equality check on the endpoint can
  never match. Assert the v2 prefix instead.
- zai-web-chat-endpoint-8014-probe.test.ts: the probe drove the executor with a
  bare cookie credential and no captcha proof, which now routes through the
  browser transport — fetch was never called and the probe captured nothing.
  Supplied a direct-path credential, and matched on pathname across all
  requests (the executor also probes the homepage for the frontend version and
  calls /api/v1/chats/new first).

The guard's intent is unchanged and slightly strengthened: it now asserts no
request reaches the stale unversioned path and that exactly one completions
request is issued, against v2.

54/54 across the zai suites; typecheck:core and eslint clean.

* fix(zai-web): surface upstream error frames instead of finishing empty

Reported on this PR: HTTP 200, `out=0`, stream "complete", no content and no
diagnosis.

Cause. HTTP-level failures are already handled — fetchUpstream turns any !ok
response into a makeErrorResult with the sanitized body. The gap is a 200 whose
SSE body carries an error payload: parseZaiFrame returns null for it,
drainSseDeltas drops it, and buildZaiStreamingBody then closes with an empty
assistant message + stop + [DONE]. The caller reads that as a successful empty
completion, so a rejected signature, an expired captcha and a stale token all
look identical — which is why this had to be diagnosed by reading code rather
than logs. Hard Rule #6.

Fix. parseZaiFrame now classifies an affirmatively error-shaped frame
(`error` at the top level or under `data`, string or {detail|message|msg}) as a
terminal delta, checked before the delta paths so it cannot fall through to the
"no usable delta" null. The stream emits it as `[Z.ai error] <message>`,
matching the mid-stream convention the other web executors already use
(zed-hosted's createErrorChunk) — the 200 is on the wire, so the status cannot
change, but the caller must not be left reading a blank success. Content
streamed before the failure is preserved. Message goes through
sanitizeErrorMessage (Rule #12).

Deliberately NOT changed: a contentless frame still parses to null. That is
live-validated behaviour, not an oversight — z.ai emits phase frames with no
delta_content, and executor-zai-web.test.ts pins it ("returns null for frames
with no usable delta"). Treating "nothing parseable arrived" as a failure would
invent policy on top of an observed protocol and risk false errors on the happy
path, so this only adds recognition of explicit error frames.

Tests (TDD, RED then GREEN): zai-web-silent-empty-repro.test.ts — 7 cases.
Error frame classified and terminal; surfaced through the stream with the
upstream's own text; surfaced after partial content without losing it; plus a
REGRESSION GUARD that contentless/phase-only frames are still skipped, and two
controls that the happy path and reasoning-only output are untouched. The guard
and controls passed before the fix; the four error cases did not.

94/94 across the zai + stream suites; typecheck:core, eslint and check:file-size
clean.

* refactor(sse): extract the zai-web transports so the complexity ratchet holds

The v3.8.49 merge-train rebaseline (#8686) set the ceiling to the tip's own
measurement, leaving zero headroom, so this branch's +5 cyclomatic / +3 cognitive
own-growth had nowhere to sit once rebased onto it.

Eight violations, all in code this branch introduces, resolved by extraction —
no behaviour change:

- `execute` (152 lines, complexity 25, cognitive 20) now delegates to
  `resolveZaiRequest()` for the four client-error rejections and to a
  `fetchViaSignedApi()` method for the CAPTCHA/signature path, so it reads as
  "validate, pick a transport, shape the response".
- `fetchThroughBrowser` (126 lines, cognitive 16) hands its image decoding to
  `resolveZaiBrowserAttachments()`, its Playwright options to
  `buildZaiBrowserChatOptions()`, and its call-log payload to
  `buildZaiBrowserAuditBody()`.
- `configureZaiBrowserEffort` (cognitive 35 — the worst of the set) repeated a
  wrap-and-relabel try/catch four times inside an if/else. `runStage`, which
  already existed one function below, is now module-scoped and reused, and the
  toggle collapses to `checked !== config.enabled` (same four cases).
- `validateWebCookieProvider` (complexity 19) moves its can-we-probe-this
  cascade into `resolveWebCookieProbe()`, which returns either a rejection or
  the URL + headers to use.
- `acquireBrowserContext`'s creation closure (complexity 17) hands cookie and
  localStorage seeding to `seedContextSession()`.

That last extraction also clears a violation that predates this branch —
`acquireBrowserContext` was already over the 80-line ceiling — so cyclomatic
lands at 2187 against a baseline of 2188.

Verified: check:complexity-ratchets green both metrics; typecheck:core clean;
ESLint clean on all four files; 85 tests across the zai-web, web-cookie
validation, browser-pool and model-test-runner suites pass.

* fix(zai-web): surface upstream errors on the non-streaming path

collectZaiNonStreaming ignored delta.error — a 200 whose SSE body carries
an error frame (rejected signature, expired captcha, stale token) came
back as a successful empty completion. Now it throws on an error frame,
matching the streaming path's [Z.ai error] convention; the caller's
existing try/catch returns makeErrorResult(502) instead of an empty 200.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: backryun <busan011@ormbiz.co.kr>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-12 08:41:03 -03:00

856 lines
27 KiB
TypeScript

/**
* 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";
import type {
BrowserBackedChatRequest,
BrowserBackedChatResult,
} from "./browserBackedChat/types.ts";
export type {
BrowserBackedChatRequest,
BrowserBackedChatResult,
} from "./browserBackedChat/types.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<string, CachedCookies>();
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<string, Promise<string | null>>();
// 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<BrowserBackedChatResult>) | null =
null;
let httpOverride: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | 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();
}
async function withAbort<T>(promise: Promise<T>, signal?: AbortSignal | null): Promise<T> {
if (!signal) return promise;
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
let abortListener: (() => void) | undefined;
const aborted = new Promise<never>((_, reject) => {
abortListener = () => reject(new DOMException("Aborted", "AbortError"));
signal.addEventListener("abort", abortListener, { once: true });
});
try {
return await Promise.race([promise, aborted]);
} finally {
if (abortListener) signal.removeEventListener("abort", abortListener);
}
}
function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise<void> {
return new Promise<void>((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 });
});
}
async function uploadBrowserAttachments(
page: import("playwright").Page,
attachments: NonNullable<BrowserBackedChatRequest["attachments"]>,
chatUrlMatchDomain: string,
signal?: AbortSignal | null
): Promise<void> {
if (attachments.length === 0) return;
const fileInput = page.locator('input[type="file"]').first();
await withAbort(fileInput.waitFor({ state: "attached", timeout: 10_000 }), signal);
for (const attachment of attachments) {
const uploadResponsePromise = page.waitForResponse(
(response) => {
if (response.request().method() !== "POST") return false;
try {
const url = new URL(response.url());
return (
url.hostname.endsWith(chatUrlMatchDomain) && /\/api\/v1\/files\/?$/.test(url.pathname)
);
} catch {
return false;
}
},
{ timeout: 30_000 }
);
const [uploadResponse] = await Promise.all([
uploadResponsePromise,
fileInput.setInputFiles({
name: attachment.name,
mimeType: attachment.mimeType,
buffer: attachment.buffer,
}),
]);
if (!uploadResponse.ok()) {
throw new Error(`attachment upload returned HTTP ${uploadResponse.status()}`);
}
// Let the provider commit its uploaded-file state before another file or
// the chat submission is triggered.
await waitWithSignal(150, signal);
}
}
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,
};
}
// 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.
export function chatUrlMatcher(u: string, matchDomain: string, chatUrl: string): boolean {
if (u === chatUrl) return true;
let parsed: URL;
let chatParsed: URL;
try {
parsed = new URL(u);
chatParsed = new URL(chatUrl);
} catch {
return false;
}
if (!parsed.host.endsWith(matchDomain)) return false;
const chatSeg = chatParsed.pathname.split("/").filter(Boolean);
const reqSeg = parsed.pathname.split("/").filter(Boolean);
if (chatSeg.length < 2 || reqSeg.length !== chatSeg.length) return false;
// All segments except the PLACEHOLDER segment must match.
let allowedDynamic = 1;
for (let i = 0; i < chatSeg.length; i++) {
if (chatSeg[i] === reqSeg[i]) continue;
if (chatSeg[i] === "PLACEHOLDER" && allowedDynamic > 0) {
allowedDynamic--;
continue;
}
return false;
}
return true;
}
export async function browserBackedChat(
req: BrowserBackedChatRequest
): Promise<BrowserBackedChatResult> {
if (testOverride) return testOverride(req);
const t0 = Date.now();
const {
poolKey,
chatUrl,
chatPageUrl,
userMessage,
cookieString,
localStorage,
localStorageOrigin,
cookieDomain,
chatUrlMatchDomain,
userAgent,
locale,
timezone,
inputSelector,
submitButtonSelector,
submitButtonMode = "playwright",
attachments = [],
beforeSubmit,
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,
localStorage,
localStorageOrigin,
warmupUrl: chatPageUrl,
userAgent,
locale,
timezone,
});
const acquireContextMs = Date.now() - tAcquireStart;
const page = await openPage(pooled);
const observedPostUrls: string[] = [];
page.on("request", (request) => {
if (request.method() !== "POST") return;
try {
const url = new URL(request.url());
if (!url.hostname.endsWith(chatUrlMatchDomain)) return;
const sanitized = `${url.origin}${url.pathname}`;
if (!observedPostUrls.includes(sanitized)) observedPostUrls.push(sanitized);
} catch {
// Ignore malformed/non-HTTP request URLs.
}
});
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;
if (beforeSubmit) {
await beforeSubmit(page);
}
await uploadBrowserAttachments(page, attachments, chatUrlMatchDomain, signal);
const inputLocator = page.locator(inputSelector).first();
await withAbort(inputLocator.waitFor({ state: "visible", timeout: 10000 }), signal);
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<never>((_, 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 {
if (submitButtonMode === "dom") {
await btn.evaluate((element) => (element as HTMLElement).click());
} else {
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) {
// Most provider streams finish well before the safety window. Return as
// soon as Playwright reports completion instead of always paying the
// full fixed delay before reading the already-buffered body.
await Promise.race([
response.finished().then(() => undefined),
waitWithSignal(Math.min(postSubmitWaitMs, 30000), 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,
observedPostUrls,
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,
observedPostUrls,
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<BrowserBackedChatResult> {
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<string, string> = {
"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,
sessionScope: req.poolKey,
});
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<string | null> {
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<string | null> {
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<string | null> {
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<string | null> {
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<import("./browserPool.ts").PooledContext | null> {
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<import("./browserPool.ts").PooledContext | null> | null
): Promise<string | null> {
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 {
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<BrowserBackedChatResult> {
const abortController = req.signal ? null : new AbortController();
const effectiveSignal = req.signal ?? abortController?.signal ?? null;
if (abortController) {
setTimeout(() => abortController.abort(), 45000);
}
// 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);
try {
const fast = await httpBackedChat({ ...req, signal: effectiveSignal ?? undefined });
if (fast.status >= 200 && fast.status < 300) return fast;
if (!isChallengeResponse(fast.status)) return fast;
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;
}
}
}
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) {
if (err instanceof DOMException && err.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 err;
}
}
export { shutdownPool };