mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 18:02:17 +03:00
Compare commits
4 Commits
fix/comp-e
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f96a97e33 | ||
|
|
e6d1f4b048 | ||
|
|
4919aa89a9 | ||
|
|
7c1e228353 |
168
docs/issues/plugin-browser-pool-proposal.md
Normal file
168
docs/issues/plugin-browser-pool-proposal.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# [Feature] Pluginization Phase 1: Extract Playwright/CloakBrowser Browser Pool
|
||||
|
||||
**Labels:** `enhancement`, `plugin`, `architecture`
|
||||
|
||||
## Problem / Use Case
|
||||
|
||||
OmniRoute's Playwright/CloakBrowser dependency is a **large, non-essential dependency** (~1500+ LOC across 22 source files + ~35 test files) pulled into every installation regardless of whether the user needs browser-backed chat. Users who run OmniRoute purely as a proxy/router (the majority) pay for:
|
||||
|
||||
- **Disk space**: ~200+ MB from Playwright browsers + Chromium binaries (installed via `npx playwright install`)
|
||||
- **Build complexity**: Turbopack must handle the `cloakbrowser` package
|
||||
- **Bundle size**: All browser-pool code is compiled into the main codebase
|
||||
- **Surface area**: 7 files with direct Playwright imports (4 dynamic, 3 static type imports)
|
||||
- **CI/cache impact**: Playwright installs in CI pipelines even when not needed
|
||||
|
||||
Currently, only environment variables (`OMNIROUTE_BROWSER_POOL=off`) gate _runtime_ execution — the code still loads, imports get resolved, and Playwright must be installed.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Extract the Playwright/CloakBrowser browser pool into an **optional package** loaded via dynamic `import()` at runtime, following the existing pattern used for `cloakbrowser` (computed-string dynamic import to avoid resolution). The core retains thin interface stubs that gracefully degrade when the optional package is absent.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
open-sse/
|
||||
interfaces/
|
||||
browserPool.ts ← NEW: BrowserPoolProvider interface + types
|
||||
services/
|
||||
browserPool.ts ← BECOMES: thin stub, delegates to optional package
|
||||
browserBackedChat.ts ← BECOMES: thin stub, delegates to optional package
|
||||
grokClearance.ts ← BECOMES: thin stub
|
||||
|
||||
packages/browser-pool/ ← NEW: optional package
|
||||
index.ts ← exports BrowserPoolProvider implementation
|
||||
src/
|
||||
browserPool.ts ← extracted from open-sse/services/browserPool.ts
|
||||
browserBackedChat.ts ← extracted from open-sse/services/browserBackedChat.ts
|
||||
grokClearance.ts ← extracted from open-sse/services/grokClearance.ts
|
||||
claudeTurnstileSolver.ts ← extracted (tightly coupled, moved as-is)
|
||||
inAppLoginService.ts ← extracted (own Playwright instance, separate lifecycle)
|
||||
package.json
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
### Phase Breakdown
|
||||
|
||||
**Phase 1 — Core pool extraction (this issue):**
|
||||
|
||||
1. Define `BrowserPoolProvider` interface in `open-sse/interfaces/browserPool.ts`
|
||||
2. Extract `browserPool.ts` (~502 LOC), `browserBackedChat.ts` (~270 LOC), `grokClearance.ts` (~84 LOC) into `packages/browser-pool/`
|
||||
3. Replace core files with thin stubs that try `import('../../../packages/browser-pool')` with graceful fallback
|
||||
4. Keep `poolTools.ts` importing the core stub (unchanged from consumer perspective)
|
||||
5. Make Playwright an optional dependency (not in root `package.json`)
|
||||
6. Typecheck core passes with and without the package installed
|
||||
7. All existing tests pass (with plugin installed)
|
||||
|
||||
**Phase 2 — Turnstile solver extraction (future):**
|
||||
|
||||
- Extract `claudeTurnstileSolver.ts` (~212 LOC) — has static Playwright type imports, needs type interface
|
||||
- Move `claudeWebAutoRefresh.ts` (depends on turnstile solver)
|
||||
|
||||
**Phase 3 — Standalone Playwright instances (future):**
|
||||
|
||||
- Extract `inAppLoginService.ts` (~257 LOC)
|
||||
- Refactor `gemini-web.ts` executor's own Playwright path (~553 LOC)
|
||||
|
||||
### Interface Design (Phase 1)
|
||||
|
||||
```typescript
|
||||
// open-sse/interfaces/browserPool.ts
|
||||
export interface BrowserPoolProvider {
|
||||
acquireBrowserContext(options?: BrowserPoolContextOptions): Promise<PooledContext>;
|
||||
releaseBrowserContext(ctx: PooledContext): Promise<void>;
|
||||
getBrowserPoolMetrics(): BrowserPoolMetrics;
|
||||
shutdownPool(): Promise<void>;
|
||||
isPoolEnabled(): boolean;
|
||||
openPage(url: string, ctx?: PooledContext): Promise<{ page: any }>;
|
||||
readPageResponseBody(page: any): Promise<string>;
|
||||
getBrowserPoolStatus(): BrowserPoolStatus;
|
||||
}
|
||||
```
|
||||
|
||||
### Stub Pattern
|
||||
|
||||
```typescript
|
||||
// open-sse/services/browserPool.ts — thin stub
|
||||
let _impl: BrowserPoolProvider | null = null;
|
||||
|
||||
async function getImpl(): Promise<BrowserPoolProvider> {
|
||||
if (!_impl) {
|
||||
try {
|
||||
const { createBrowserPoolProvider } = await import("../../packages/browser-pool");
|
||||
_impl = createBrowserPoolProvider();
|
||||
} catch {
|
||||
// Graceful fallback — disabled
|
||||
_impl = createNullBrowserPoolProvider();
|
||||
}
|
||||
}
|
||||
return _impl;
|
||||
}
|
||||
|
||||
export async function acquireBrowserContext(...args) {
|
||||
return (await getImpl()).acquireBrowserContext(...args);
|
||||
}
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Existing hook-based PluginManager**: Rejected. The current PluginManager operates via child-process IPC and request-pipeline hooks (`onRequest`, `onResponse`, `onError`). A browser pool is an in-process runtime service with composable lifecycle — not a request pipeline hook. Forcing it through IPC would add ~50ms+ per browser operation and break the existing synchronous pool pattern.
|
||||
|
||||
2. **Keep as-is, just lazy-load the import**: Minimal improvement — the dependency tree still references Playwright types, requiring it to be available. Doesn't reduce bundle size or simplify CI.
|
||||
|
||||
3. **Replace Playwright with a protocol-level abstraction**: Too ambitious and would change the behavior of the pool. Playwright's CDP capabilities (context isolation, cookies, screenshots) are fundamental to how the pool works.
|
||||
|
||||
4. **Monorepo workspace**: Too heavy for this scope. A simple extracted package avoids workspace tooling changes.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `open-sse/interfaces/browserPool.ts` exists and exports `BrowserPoolProvider`, `PooledContext`, `BrowserPoolMetrics` types
|
||||
2. `open-sse/services/browserPool.ts` becomes a thin stub with zero Playwright imports
|
||||
3. `packages/browser-pool/` contains all extracted implementation (browserPool, browserBackedChat, grokClearance)
|
||||
4. Core typecheck (`npm run typecheck:core`) passes with 0 errors **without** the browser-pool package installed
|
||||
5. Core typecheck passes with the package installed
|
||||
6. All existing tests pass when the browser-pool package is installed
|
||||
7. `poolTools.ts` `omniroute_browser_pool_status` tool works end-to-end when the package is installed
|
||||
8. Graceful degradation: when the package is absent, `getBrowserPoolStatus()` returns `{ enabled: false }` without crashing
|
||||
9. Playwright is moved from root `dependencies` to optional/peer in the extracted package
|
||||
10. Documentation updated in `docs/reference/ENVIRONMENT.md`
|
||||
|
||||
## Expected Test Plan
|
||||
|
||||
- Unit tests for the stub fallback path (simulate import failure, verify graceful degradation)
|
||||
- Unit tests moved to the extracted package
|
||||
- Verify `tests/unit/browser-pool-optional-import.test.ts` passes (still validates cloakbrowser isn't statically resolved)
|
||||
- Verify `tests/unit/browserPool-proxy.test.ts` passes
|
||||
- Verify `tests/unit/browserBackedChat-matcher.test.ts` passes
|
||||
- E2E: `npm run typecheck:core` without the package installed → 0 errors
|
||||
- E2E: `npm run test:coverage` (with package installed) → existing coverage gates pass
|
||||
|
||||
## Additional Context
|
||||
|
||||
Current dependency graph (simplified):
|
||||
|
||||
```
|
||||
open-sse/services/browserPool.ts (502 LOC, singleton Playwright/CloakBrowser pool)
|
||||
├── open-sse/services/browserBackedChat.ts (270 LOC, browser-backed chat runner)
|
||||
│ ├── open-sse/executors/claude-web.ts (imports tryBackedChat)
|
||||
│ └── open-sse/executors/duckduckgo-web.ts (imports tryBackedChat)
|
||||
├── open-sse/services/grokClearance.ts (84 LOC, CF clearance via browser)
|
||||
└── open-sse/mcp-server/tools/poolTools.ts (imports getBrowserPoolMetrics)
|
||||
|
||||
Standalone Playwright users (separate, future phases):
|
||||
├── open-sse/services/claudeTurnstileSolver.ts (212 LOC, static Playwright type imports)
|
||||
├── open-sse/services/inAppLoginService.ts (257 LOC, own browser lifecycle)
|
||||
└── open-sse/executors/gemini-web.ts (553 LOC, private Playwright path)
|
||||
|
||||
Kill switches: OMNIROUTE_BROWSER_POOL, WEB_COOKIE_USE_BROWSER (both env vars)
|
||||
```
|
||||
|
||||
Total extracted in Phase 1: ~856 LOC, 3 files.
|
||||
Total deferred to Phase 2/3: ~1022 LOC, 4 files.
|
||||
|
||||
This is the first pluginization step. Future targets (separate issues): memory/compression plugin, additional provider support extraction.
|
||||
|
||||
## Related References
|
||||
|
||||
- PR #8219 (model catalog connection filter + cache TTL) — same baseline `release/v3.8.49`
|
||||
- `docs/reference/ENVIRONMENT.md` — browser pool env vars documentation
|
||||
- Plugin system docs at `docs/PLUGINS.md` — existing PluginManager (not used here, referenced for contrast)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 caller-defined isolation key) 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;
|
||||
@@ -49,10 +40,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;
|
||||
@@ -65,111 +52,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<string, PooledContext>;
|
||||
pendingContexts: Map<string, Promise<PooledContext>>;
|
||||
launching: Promise<Browser> | null;
|
||||
lastActivity: number;
|
||||
idleTimer: NodeJS.Timeout | null;
|
||||
evictTimer: NodeJS.Timeout | null;
|
||||
cloakLaunch: ((opts: unknown) => Promise<Browser>) | 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<Browser>) | null> {
|
||||
if (state.cloakLaunchResolved) return state.cloakLaunch;
|
||||
state.cloakLaunchResolved = true;
|
||||
try {
|
||||
const mod = (await import(getCloakbrowserModuleId())) as unknown as {
|
||||
launch?: (opts: unknown) => Promise<Browser>;
|
||||
};
|
||||
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",
|
||||
"(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;
|
||||
host: string;
|
||||
@@ -182,7 +67,54 @@ interface ResolvePlaywrightProxyDeps {
|
||||
resolveProxy?: (providerId: string) => Promise<ProxyRecord | null | undefined>;
|
||||
}
|
||||
|
||||
// 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<typeof import("@omniroute/browser-pool")> | null = null;
|
||||
|
||||
function getMod(): Promise<typeof import("@omniroute/browser-pool")> {
|
||||
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,6 +146,13 @@ export async function resolvePlaywrightProxy(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the proxy for a browser-pool context key. Scoped context keys
|
||||
* (e.g. "claude-web:account-scope") carry a stable `proxyProviderKey` so the
|
||||
* proxy lookup always targets the underlying provider, not the scoped key.
|
||||
* Kept inline alongside resolvePlaywrightProxy — trivial wrapper, no
|
||||
* playwright/browser-pool dependency.
|
||||
*/
|
||||
export async function resolveBrowserContextProxy(
|
||||
contextKey: string,
|
||||
options: Pick<BrowserPoolContextOptions, "proxyProviderKey">,
|
||||
@@ -222,251 +161,14 @@ export async function resolveBrowserContextProxy(
|
||||
return resolvePlaywrightProxy(options.proxyProviderKey ?? contextKey, deps);
|
||||
}
|
||||
|
||||
async function launchBrowser(): Promise<Browser> {
|
||||
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<PooledContext> {
|
||||
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<PooledContext> => {
|
||||
const [browser, proxy] = await Promise.all([
|
||||
launchBrowser(),
|
||||
resolveBrowserContextProxy(key, options),
|
||||
]);
|
||||
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<Page> {
|
||||
return pooled.context.newPage();
|
||||
}
|
||||
|
||||
export async function releaseBrowserContext(key: string): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
@@ -475,38 +177,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<PooledContext> {
|
||||
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<typeof getBrowserPoolStatus>;
|
||||
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<void> {
|
||||
const mod = await getMod();
|
||||
return mod.releaseBrowserContext(key);
|
||||
}
|
||||
|
||||
export async function shutdownPool(reason: string): Promise<void> {
|
||||
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<void> {
|
||||
const mod = await getMod();
|
||||
return mod.__resetBrowserPoolMetricsForTest();
|
||||
}
|
||||
|
||||
export async function readPageResponseBody(
|
||||
response: import("playwright").Response
|
||||
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
|
||||
const headers: Record<string, string> = {};
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,3 @@
|
||||
/**
|
||||
* 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 { 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 +5,20 @@ export function shouldUseGrokBrowserBacked(): boolean {
|
||||
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
|
||||
}
|
||||
|
||||
type AcquireGrokClearanceFn = (signal?: AbortSignal | null) => Promise<string | null>;
|
||||
|
||||
// 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:
|
||||
((signal?: AbortSignal | null) => Promise<string | null>)
|
||||
| null = null;
|
||||
|
||||
export function __setGrokClearanceAcquireOverrideForTesting(
|
||||
fn: AcquireGrokClearanceFn | null
|
||||
fn: ((signal?: AbortSignal | null) => Promise<string | null>) | null,
|
||||
): void {
|
||||
acquireOverride = fn;
|
||||
grokClearanceAcquireOverride = fn;
|
||||
}
|
||||
|
||||
async function readCfClearanceFromContext(pooled: PooledContext): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
if (acquireOverride) return acquireOverride(signal);
|
||||
try {
|
||||
return await acquireViaPool();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
export async function acquireFreshGrokClearance(
|
||||
signal?: AbortSignal | null,
|
||||
): Promise<string | null> {
|
||||
if (grokClearanceAcquireOverride) return grokClearanceAcquireOverride(signal);
|
||||
const mod = await import("@omniroute/browser-pool");
|
||||
return mod.acquireFreshGrokClearance(signal);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@omniroute/open-sse": ["./open-sse"],
|
||||
"@omniroute/open-sse/*": ["./open-sse/*"]
|
||||
"@omniroute/open-sse/*": ["./open-sse/*"],
|
||||
"@omniroute/browser-pool": ["./packages/browser-pool/src"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.js"]
|
||||
|
||||
112
package-lock.json
generated
112
package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"open-sse"
|
||||
"open-sse",
|
||||
"packages/browser-pool"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1073.0",
|
||||
@@ -3709,9 +3710,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3728,9 +3726,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3747,9 +3742,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3766,9 +3758,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3785,9 +3774,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3804,9 +3790,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3823,9 +3806,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3842,9 +3822,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3861,9 +3838,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3886,9 +3860,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3911,9 +3882,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3936,9 +3904,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3961,9 +3926,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3986,9 +3948,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4011,9 +3970,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4036,9 +3992,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6347,6 +6300,10 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@omniroute/browser-pool": {
|
||||
"resolved": "packages/browser-pool",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@omniroute/open-sse": {
|
||||
"resolved": "open-sse",
|
||||
"link": true
|
||||
@@ -10735,9 +10692,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10755,9 +10709,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10775,9 +10726,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10795,9 +10743,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -12890,9 +12835,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12906,9 +12848,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12922,9 +12861,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12938,9 +12874,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12954,9 +12887,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12970,9 +12900,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -37044,6 +36971,33 @@
|
||||
"safe-regex": "^2.1.1",
|
||||
"smol-toml": "1.7.0"
|
||||
}
|
||||
},
|
||||
"packages/browser-pool": {
|
||||
"name": "@omniroute/browser-pool",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22"
|
||||
}
|
||||
},
|
||||
"packages/browser-pool/node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"packages/browser-pool/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"!**/*.spec.tsx"
|
||||
],
|
||||
"workspaces": [
|
||||
"open-sse"
|
||||
"open-sse",
|
||||
"packages/browser-pool"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.22.2 <23 || >=24.0.0 <27"
|
||||
|
||||
15
packages/browser-pool/package.json
Normal file
15
packages/browser-pool/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@omniroute/browser-pool",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Optional browser pool service for OmniRoute — CloakBrowser and Playwright-backed chat",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22"
|
||||
}
|
||||
}
|
||||
37
packages/browser-pool/src/index.ts
Normal file
37
packages/browser-pool/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @omniroute/browser-pool — Optional browser pool for Playwright-backed
|
||||
* executor support (claude-web, duckduckgo-web, grok).
|
||||
*
|
||||
* Core stubs dynamically import this package at runtime. When the package
|
||||
* is not installed, the stubs degrade gracefully (fallback or error).
|
||||
*/
|
||||
|
||||
// ── Re-exports from browserPool ──────────────────────────────────────────
|
||||
export {
|
||||
acquireBrowserContext,
|
||||
releaseBrowserContext,
|
||||
getBrowserPoolMetrics,
|
||||
readPageResponseBody,
|
||||
openPage,
|
||||
shutdownPool,
|
||||
setProxyResolver,
|
||||
__resetBrowserPoolMetricsForTest,
|
||||
} from "./services/browserPool.ts";
|
||||
|
||||
export type { BrowserPoolContextOptions, BrowserPoolMetrics, PooledContext } from "./interfaces.ts";
|
||||
|
||||
// ── Re-exports from browserBackedChat ────────────────────────────────────
|
||||
export {
|
||||
browserBackedChat,
|
||||
startBrowserWarmup,
|
||||
getFreshCookiesWithWarmup,
|
||||
} from "./services/browserBackedChat.ts";
|
||||
|
||||
// ── Re-exports from grokClearance ─────────────────────────────────────────
|
||||
export {
|
||||
getCachedCookies,
|
||||
setCachedCookies,
|
||||
clearCookieCache,
|
||||
} from "./services/browserBackedChat.ts";
|
||||
|
||||
export { shouldUseGrokBrowserBacked, acquireFreshGrokClearance } from "./services/grokClearance.ts";
|
||||
94
packages/browser-pool/src/interfaces.ts
Normal file
94
packages/browser-pool/src/interfaces.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* interfaces.ts — Shared type definitions for @omniroute/browser-pool.
|
||||
*
|
||||
* These types are used by both the package entry and the core stubs.
|
||||
* The core stubs re-export them so existing import paths remain stable.
|
||||
*/
|
||||
|
||||
import type { BrowserContext, Page } from "playwright";
|
||||
|
||||
// ── Browser pool ───────────────────────────────────────
|
||||
|
||||
export interface BrowserPoolContextOptions {
|
||||
cookieDomain: string;
|
||||
cookieString?: string | null;
|
||||
warmupUrl?: string | null;
|
||||
userAgent?: string;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
preferCloakbrowser?: boolean;
|
||||
/** Time (ms) to wait for the warmup page to be ready. */
|
||||
waitFor?: number;
|
||||
}
|
||||
|
||||
export interface PooledContext {
|
||||
id: string;
|
||||
context: BrowserContext;
|
||||
warmupPage: Page | null;
|
||||
lastUsed: number;
|
||||
isStealth: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserPoolMetrics {
|
||||
browserLaunches: number;
|
||||
browserLaunchFailures: number;
|
||||
contextsCreated: number;
|
||||
contextsReused: number;
|
||||
contextsEvicted: number;
|
||||
contextsReleased: number;
|
||||
contextCreateFailures: number;
|
||||
shutdowns: number;
|
||||
lastShutdownReason: string | null;
|
||||
}
|
||||
|
||||
// ── Browser-backed chat ────────────────────────────────
|
||||
|
||||
export interface BrowserBackedChatRequest {
|
||||
/** Pool key — typically a provider id like "duckduckgo-web" or
|
||||
* "claude-web", optionally suffixed by user/account id. */
|
||||
poolKey: string;
|
||||
/** Chat URL the page should submit to (captured via waitForResponse). */
|
||||
chatUrl: string;
|
||||
/** Chat page URL to navigate to before typing. */
|
||||
chatPageUrl: string;
|
||||
/** The text the user wants to send. */
|
||||
userMessage: string;
|
||||
/** Cookie string (raw) to inject into the browser context. */
|
||||
cookieString?: string | null;
|
||||
/** Cookie domain (used together with cookieString). */
|
||||
cookieDomain?: string;
|
||||
/** Domain for the page's fetch to identify the chat endpoint. */
|
||||
chatUrlMatchDomain: string;
|
||||
/** User-Agent string for the browser context. */
|
||||
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. */
|
||||
inputSelector: string;
|
||||
/** Selector for the submit button (optional — falls back to Enter). */
|
||||
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. Default true. */
|
||||
reuseContext?: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserBackedChatTiming {
|
||||
acquireContextMs: number;
|
||||
navigateMs: number;
|
||||
submitMs: number;
|
||||
captureResponseMs: number;
|
||||
totalMs: number;
|
||||
}
|
||||
|
||||
export interface BrowserBackedChatResult {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
body: Buffer;
|
||||
isStealth: boolean;
|
||||
timing: BrowserBackedChatTiming;
|
||||
}
|
||||
461
packages/browser-pool/src/services/browserBackedChat.ts
Normal file
461
packages/browser-pool/src/services/browserBackedChat.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* browserBackedChat.ts — Full browser-backed chat interaction for @omniroute/browser-pool.
|
||||
*
|
||||
* 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 structured result.
|
||||
*
|
||||
* Providers using this path: duckduckgo-web, claude-web.
|
||||
*
|
||||
* The browser solves the provider's challenge natively (VQD, Cloudflare
|
||||
* Turnstile, etc.) by computing real DOM measurement values. The
|
||||
* Node-side challenge solver still runs as a first-line best-effort;
|
||||
* this module is the fallback.
|
||||
*/
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
acquireBrowserContext,
|
||||
openPage,
|
||||
readPageResponseBody,
|
||||
releaseBrowserContext,
|
||||
} from "./browserPool.ts";
|
||||
import type {
|
||||
PooledContext,
|
||||
BrowserBackedChatRequest,
|
||||
BrowserBackedChatResult,
|
||||
} from "../interfaces.ts";
|
||||
|
||||
// Safety constants
|
||||
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// Cookie cache constants
|
||||
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
|
||||
|
||||
// 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>();
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function setCachedCookies(domain: string, cookieString: string, ttlMs?: number): void {
|
||||
cookieCache.set(domain, {
|
||||
cookieString,
|
||||
expiresAt: Date.now() + (ttlMs ?? COOKIE_CACHE_TTL_MS),
|
||||
domain,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearCookieCache(): void {
|
||||
cookieCache.clear();
|
||||
}
|
||||
|
||||
// Dedup pending cookie refreshes per pool key
|
||||
const pendingRefreshes = new Map<string, Promise<string | null>>();
|
||||
|
||||
/** Sanitize an error message for safe JSON transport. */
|
||||
const MAX_ERROR_LEN = 512;
|
||||
function sanitizeErrorMessage(message: unknown): string {
|
||||
let str = typeof message === "string" ? message : String(message ?? "");
|
||||
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
|
||||
const nl = str.indexOf("\n");
|
||||
if (nl >= 0) str = str.slice(0, nl);
|
||||
return str.replace(/[^ -~]/g, "").trim();
|
||||
}
|
||||
|
||||
/** Wait N milliseconds, abortable via signal. */
|
||||
async function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise<void> {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* NOTE: Does NOT pass AbortSignal to Playwright methods — signals are
|
||||
* handled via waitWithSignal wrapping instead.
|
||||
*/
|
||||
async function doCookieRefreshOnContext(
|
||||
pooled: 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,
|
||||
});
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a URL against a chat URL template, allowing a single dynamic
|
||||
* id segment (PLACEHOLDER) in the template.
|
||||
*/
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Resolve a unique pool key; when reuseContext is false, create a unique key. */
|
||||
async function settlePoolKey(
|
||||
requestedKey: string,
|
||||
reuseContext: boolean
|
||||
): Promise<{ key: string; acquired: boolean }> {
|
||||
if (reuseContext) return { key: requestedKey, acquired: true };
|
||||
return {
|
||||
key: `${requestedKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
acquired: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Cookie refresh helpers ──────────────────────────
|
||||
|
||||
/**
|
||||
* doRefresh — Acquire a fresh browser context, navigate to the
|
||||
* chat page, and poll for cookies. Returns the cookie string
|
||||
* or null on failure.
|
||||
* NOTE: Does NOT pass AbortSignal to Playwright methods — signals
|
||||
* are handled via waitWithSignal wrapping instead.
|
||||
*/
|
||||
async function doRefresh(options: {
|
||||
chatPageUrl: string;
|
||||
cookieDomain: string;
|
||||
poolKey: string;
|
||||
signal: AbortSignal | null;
|
||||
}): Promise<string | null> {
|
||||
const pooled = await acquireBrowserContext(options.poolKey + "-refresh", {
|
||||
cookieDomain: options.cookieDomain,
|
||||
cookieString: null,
|
||||
warmupUrl: options.chatPageUrl,
|
||||
});
|
||||
const page = await openPage(pooled);
|
||||
try {
|
||||
await page.goto(options.chatPageUrl, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
});
|
||||
return await waitForCookiesWithPolling(pooled.context, options.cookieDomain, options.signal);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
return null;
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
// release context — we got the cookies
|
||||
setTimeout(() => {
|
||||
releaseBrowserContext(options.poolKey + "-refresh").catch(() => {});
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshCookiesViaBrowser — Refresh cookies using a browser context.
|
||||
* Uses pendingRefreshes dedup so concurrent requests share one browser launch.
|
||||
* NOTE: Override check (httpOverride) is handled in the core stub — this
|
||||
* package version always attempts browser cookie refresh.
|
||||
*/
|
||||
async function refreshCookiesViaBrowser(
|
||||
chatUrl: string,
|
||||
chatPageUrl: string,
|
||||
cookieDomain: string,
|
||||
poolKey: string,
|
||||
signal: AbortSignal | null
|
||||
): Promise<string | null> {
|
||||
const pending = pendingRefreshes.get(poolKey);
|
||||
if (pending) return pending;
|
||||
const promise = doRefresh({ chatPageUrl, cookieDomain, poolKey, signal });
|
||||
pendingRefreshes.set(poolKey, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
pendingRefreshes.delete(poolKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* startBrowserWarmup — Pre-warm a browser context for the given pool key.
|
||||
* This is a fire-and-forget operation: errors are caught and ignored.
|
||||
* The warmup page serves as a readiness indicator — we open a page in the
|
||||
* pooled context to force early navigation before the actual request.
|
||||
*/
|
||||
export async function startBrowserWarmup(
|
||||
poolKey: string,
|
||||
chatPageUrl: string,
|
||||
cookieDomain: string,
|
||||
signal: AbortSignal | null
|
||||
): Promise<void> {
|
||||
if (process.env.OMNIROUTE_BROWSER_POOL === "off") return;
|
||||
const pooled = await acquireBrowserContext(poolKey, {
|
||||
cookieDomain,
|
||||
cookieString: null,
|
||||
warmupUrl: chatPageUrl,
|
||||
waitFor: 2000,
|
||||
});
|
||||
// Warmup: open a page in the pooled context — this can happen in parallel
|
||||
openPage(pooled).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* getFreshCookiesWithWarmup — Try cached cookies first; if none, start
|
||||
* a browser warmup in parallel with a cookie refresh. Returns cookie string
|
||||
* or null. Caches successful results.
|
||||
*/
|
||||
export async function getFreshCookiesWithWarmup(
|
||||
chatUrl: string,
|
||||
chatPageUrl: string,
|
||||
cookieDomain: string,
|
||||
poolKey: string,
|
||||
signal: AbortSignal | null
|
||||
): Promise<string | null> {
|
||||
// Try cached cookies first
|
||||
const cached = getCachedCookies(cookieDomain);
|
||||
if (cached) return cached;
|
||||
|
||||
// Start warmup in parallel with refresh
|
||||
const warmup = startBrowserWarmup(poolKey, chatPageUrl, cookieDomain, signal);
|
||||
const fresh = await refreshCookiesViaBrowser(chatUrl, chatPageUrl, cookieDomain, poolKey, signal);
|
||||
// Await warmup (errors are non-fatal)
|
||||
await warmup.catch(() => {});
|
||||
if (fresh) {
|
||||
setCachedCookies(cookieDomain, fresh);
|
||||
return fresh;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Main entry point ───────────────────────────────────
|
||||
|
||||
export async function browserBackedChat(
|
||||
req: BrowserBackedChatRequest
|
||||
): Promise<BrowserBackedChatResult> {
|
||||
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,
|
||||
});
|
||||
const navigateMs = Date.now() - tNavStart;
|
||||
|
||||
const inputLocator = page.locator(inputSelector).first();
|
||||
await inputLocator.waitFor({ state: "visible", timeout: 10000 });
|
||||
await waitWithSignal(800, signal);
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === "POST" && chatUrlMatcher(r.url(), chatUrlMatchDomain, chatUrl),
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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 = Buffer.alloc(0);
|
||||
if (response) {
|
||||
const captured = await readPageResponseBody(response);
|
||||
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 {
|
||||
body = captured.body as unknown as Buffer;
|
||||
contentType = captured.headers["content-type"] || null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
try {
|
||||
await pooled.context.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
440
packages/browser-pool/src/services/browserPool.ts
Normal file
440
packages/browser-pool/src/services/browserPool.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* browserPool.ts — Shared stealth browser pool for web-cookie providers.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
import type {
|
||||
BrowserPoolContextOptions,
|
||||
BrowserPoolMetrics,
|
||||
PooledContext,
|
||||
} from "../interfaces.ts";
|
||||
|
||||
type Browser = import("playwright").Browser;
|
||||
type BrowserContext = import("playwright").BrowserContext;
|
||||
type Page = import("playwright").Page;
|
||||
|
||||
/** Proxy resolver injected by the core stub after dynamic import. */
|
||||
type ProxyResolverFn = (
|
||||
providerKey: string
|
||||
) => Promise<import("playwright").LaunchOptions["proxy"] | undefined>;
|
||||
|
||||
let injectedProxyResolver: ProxyResolverFn | null = null;
|
||||
|
||||
export function setProxyResolver(fn: ProxyResolverFn): void {
|
||||
injectedProxyResolver = fn;
|
||||
}
|
||||
|
||||
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<string, PooledContext>;
|
||||
pendingContexts: Map<string, Promise<PooledContext>>;
|
||||
launching: Promise<Browser> | null;
|
||||
lastActivity: number;
|
||||
idleTimer: NodeJS.Timeout | null;
|
||||
evictTimer: NodeJS.Timeout | null;
|
||||
cloakLaunch: ((opts: unknown) => Promise<Browser>) | 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<Browser>) | null> {
|
||||
if (state.cloakLaunchResolved) return state.cloakLaunch;
|
||||
state.cloakLaunchResolved = true;
|
||||
try {
|
||||
const mod = (await import(getCloakbrowserModuleId())) as unknown as {
|
||||
launch?: (opts: unknown) => Promise<Browser>;
|
||||
};
|
||||
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?.();
|
||||
}
|
||||
|
||||
async function launchBrowser(): Promise<Browser> {
|
||||
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<PooledContext> {
|
||||
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<PooledContext> => {
|
||||
const proxy = injectedProxyResolver ? await injectedProxyResolver(key) : undefined;
|
||||
const [browser] = await Promise.all([launchBrowser()]);
|
||||
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<Page> {
|
||||
return pooled.context.newPage();
|
||||
}
|
||||
|
||||
export async function releaseBrowserContext(key: string): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
function getBrowserPoolStatus(): {
|
||||
enabled: boolean;
|
||||
contexts: number;
|
||||
browserRunning: boolean;
|
||||
stealthAvailable: boolean;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #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.
|
||||
*/
|
||||
export function getBrowserPoolMetrics(): {
|
||||
status: ReturnType<typeof getBrowserPoolStatus>;
|
||||
metrics: BrowserPoolMetrics;
|
||||
} {
|
||||
return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } };
|
||||
}
|
||||
|
||||
/** Test-only: reset cumulative metrics so assertions start from a clean slate. */
|
||||
export function __resetBrowserPoolMetricsForTest(): void {
|
||||
state.metrics = createBrowserPoolMetrics();
|
||||
}
|
||||
|
||||
export async function readPageResponseBody(
|
||||
response: import("playwright").Response
|
||||
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
|
||||
const headers: Record<string, string> = {};
|
||||
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) };
|
||||
}
|
||||
73
packages/browser-pool/src/services/grokClearance.ts
Normal file
73
packages/browser-pool/src/services/grokClearance.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 { acquireBrowserContext } from "./browserPool.ts";
|
||||
import type { PooledContext } from "../interfaces.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;
|
||||
const poolFlag = process.env.OMNIROUTE_BROWSER_POOL;
|
||||
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
|
||||
}
|
||||
|
||||
async function readCfClearanceFromContext(pooled: PooledContext): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
try {
|
||||
return await acquireViaPool();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
17
packages/browser-pool/tsconfig.json
Normal file
17
packages/browser-pool/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": false,
|
||||
"lib": ["esnext"],
|
||||
"types": ["node"],
|
||||
"allowJs": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "open-sse"
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
|
||||
92
tests/unit/browserBackedChat-optional-package-absent.test.ts
Normal file
92
tests/unit/browserBackedChat-optional-package-absent.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
__resetBrowserPoolModOverrideForTesting,
|
||||
__resetHttpBackedChatOverrideForTesting,
|
||||
__setBrowserPoolModOverrideForTesting,
|
||||
__setHttpBackedChatOverrideForTesting,
|
||||
tryBackedChat,
|
||||
} from "../../open-sse/services/browserBackedChat.ts";
|
||||
|
||||
// Regression test for the "optional @omniroute/browser-pool package absent"
|
||||
// path in tryBackedChat(). Before the fix, when the upstream returned a
|
||||
// challenge response (e.g. 403) and the optional browser-pool package was
|
||||
// not installed (getMod() resolves to null — the same shape a real failed
|
||||
// `import("@omniroute/browser-pool")` produces), tryBackedChat() silently
|
||||
// returned the stale challenge result instead of surfacing a clear error —
|
||||
// callers (claude-web, duckduckgo-web) would then treat the unsolved
|
||||
// challenge as a definitive upstream response.
|
||||
//
|
||||
// __setBrowserPoolModOverrideForTesting(null) simulates the module-absent
|
||||
// case deterministically (same as getMod() catching a failed dynamic
|
||||
// import), without depending on a real failing dynamic import of a
|
||||
// nonexistent package.
|
||||
describe("tryBackedChat — optional @omniroute/browser-pool package absent", () => {
|
||||
it("throws a descriptive error instead of silently returning the stale challenge response", async () => {
|
||||
__setBrowserPoolModOverrideForTesting(null);
|
||||
__setHttpBackedChatOverrideForTesting(async () => ({
|
||||
status: 403,
|
||||
contentType: "text/html",
|
||||
body: Buffer.from("<html>Just a moment...</html>"),
|
||||
isStealth: true,
|
||||
timing: {
|
||||
acquireContextMs: 0,
|
||||
navigateMs: 0,
|
||||
submitMs: 0,
|
||||
captureResponseMs: 0,
|
||||
totalMs: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
tryBackedChat({
|
||||
poolKey: "duckduckgo-web",
|
||||
chatUrl: "https://duck.ai/duckchat/v1/chat",
|
||||
userMessage: "hello",
|
||||
}),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error);
|
||||
// Must NOT resolve with the stale challenge body — must throw with
|
||||
// enough context to diagnose (challenge status + missing package).
|
||||
assert.match(err.message, /challenge/i);
|
||||
assert.match(err.message, /@omniroute\/browser-pool/);
|
||||
assert.match(err.message, /403/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
__resetHttpBackedChatOverrideForTesting();
|
||||
__resetBrowserPoolModOverrideForTesting();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the httpResult directly when it is not a challenge response (2xx path unaffected)", async () => {
|
||||
__setHttpBackedChatOverrideForTesting(async () => ({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: Buffer.from(JSON.stringify({ ok: true })),
|
||||
isStealth: true,
|
||||
timing: {
|
||||
acquireContextMs: 0,
|
||||
navigateMs: 0,
|
||||
submitMs: 0,
|
||||
captureResponseMs: 0,
|
||||
totalMs: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await tryBackedChat({
|
||||
poolKey: "duckduckgo-web",
|
||||
chatUrl: "https://duck.ai/duckchat/v1/chat",
|
||||
userMessage: "hello",
|
||||
});
|
||||
assert.equal(result.status, 200);
|
||||
} finally {
|
||||
__resetHttpBackedChatOverrideForTesting();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,8 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@omniroute/open-sse": ["./open-sse"],
|
||||
"@omniroute/open-sse/*": ["./open-sse/*"]
|
||||
"@omniroute/open-sse/*": ["./open-sse/*"],
|
||||
"@omniroute/browser-pool": ["./packages/browser-pool/src"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user