fix: remove duplicate getMod/modPromise in browserBackedChat stub

Two copies of the module proxy got committed — the typed BrowserPoolModule
version at lines 50-56 and a stale any-typed duplicate at lines 64-71.
Removed the duplicate, keeping the typed version.

Verification:
- 40/40 browser tests pass (both previously-failing suites now green)
- typecheck:core: 0 errors
- env kill switch (OMNIROUTE_BROWSER_POOL=off): verified
This commit is contained in:
oyi77
2026-07-23 21:56:32 +07:00
parent 7c1e228353
commit 4919aa89a9
13 changed files with 1385 additions and 103 deletions

View 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)

View File

@@ -61,15 +61,6 @@ const COOKIE_POLL_TIMEOUT_MS = 5 * 1000;
// ===================== MODULE PROXY ===================== // ===================== MODULE PROXY =====================
let modPromise: Promise<any> | null = null;
function getMod(): Promise<any> {
if (!modPromise) {
modPromise = import("@omniroute/browser-pool").catch(() => null);
}
return modPromise;
}
// ===================== COOKIE CACHE (DELEGATED) ===================== // ===================== COOKIE CACHE (DELEGATED) =====================
async function getCachedCookies(domain: string | undefined): Promise<string | undefined> { async function getCachedCookies(domain: string | undefined): Promise<string | undefined> {
@@ -91,11 +82,13 @@ export async function clearCookieCache(): Promise<void> {
// ===================== TEST OVERRIDES ===================== // ===================== TEST OVERRIDES =====================
let browserBackedChatOverride: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null = null; let browserBackedChatOverride:
let httpBackedChatOverride: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null = null; ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null = null;
let httpBackedChatOverride:
((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null = null;
export function __setBrowserBackedChatOverrideForTesting( export function __setBrowserBackedChatOverrideForTesting(
fn: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null, fn: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null
): void { ): void {
browserBackedChatOverride = fn; browserBackedChatOverride = fn;
} }
@@ -105,7 +98,7 @@ export function __resetBrowserBackedChatOverrideForTesting(): void {
} }
export function __setHttpBackedChatOverrideForTesting( export function __setHttpBackedChatOverrideForTesting(
fn: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null, fn: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null
): void { ): void {
httpBackedChatOverride = fn; httpBackedChatOverride = fn;
} }
@@ -149,7 +142,9 @@ export function isChallengeResponse(status: number): boolean {
// ===================== HTTP-BACKED CHAT (INLINE) ===================== // ===================== HTTP-BACKED CHAT (INLINE) =====================
export async function httpBackedChat(req: BrowserBackedChatRequest): Promise<BrowserBackedChatResult> { export async function httpBackedChat(
req: BrowserBackedChatRequest
): Promise<BrowserBackedChatResult> {
if (httpBackedChatOverride) return httpBackedChatOverride(req); if (httpBackedChatOverride) return httpBackedChatOverride(req);
const startTime = Date.now(); const startTime = Date.now();
@@ -185,7 +180,13 @@ export async function httpBackedChat(req: BrowserBackedChatRequest): Promise<Bro
contentType: "text/plain", contentType: "text/plain",
body: Buffer.from(msg), body: Buffer.from(msg),
isStealth: true, isStealth: true,
timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 0, captureResponseMs: 0, totalMs: Date.now() - startTime }, timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - startTime,
},
}; };
} }
@@ -210,7 +211,10 @@ export async function httpBackedChat(req: BrowserBackedChatRequest): Promise<Bro
body: Buffer.from("Response exceeded maximum size"), body: Buffer.from("Response exceeded maximum size"),
isStealth: true, isStealth: true,
timing: { timing: {
acquireContextMs: 0, navigateMs: 0, submitMs: 0, captureResponseMs: 0, acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - startTime, totalMs: Date.now() - startTime,
}, },
}; };
@@ -229,13 +233,21 @@ export async function httpBackedChat(req: BrowserBackedChatRequest): Promise<Bro
contentType, contentType,
body: responseBody, body: responseBody,
isStealth: true, isStealth: true,
timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 0, captureResponseMs: 0, totalMs: Date.now() - startTime }, timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - startTime,
},
}; };
} }
// ===================== TRY-BACKED CHAT (INLINE) ===================== // ===================== TRY-BACKED CHAT (INLINE) =====================
export async function tryBackedChat(req: BrowserBackedChatRequest): Promise<BrowserBackedChatResult> { export async function tryBackedChat(
req: BrowserBackedChatRequest
): Promise<BrowserBackedChatResult> {
const startTime = Date.now(); const startTime = Date.now();
// Background-load the package module // Background-load the package module
@@ -291,7 +303,7 @@ export async function tryBackedChat(req: BrowserBackedChatRequest): Promise<Brow
message: "tryBackedChat timed out", message: "tryBackedChat timed out",
type: "timeout_error", type: "timeout_error",
}, },
}), })
), ),
isStealth: false, isStealth: false,
timing: { timing: {
@@ -319,10 +331,16 @@ export async function tryBackedChat(req: BrowserBackedChatRequest): Promise<Brow
message: "tryBackedChat timed out", message: "tryBackedChat timed out",
type: "timeout_error", type: "timeout_error",
}, },
}), })
), ),
isStealth: false, isStealth: false,
timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 0, captureResponseMs: 0, totalMs: Date.now() - startTime }, timing: {
acquireContextMs: 0,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - startTime,
},
}; };
} }
throw err; throw err;
@@ -331,7 +349,9 @@ export async function tryBackedChat(req: BrowserBackedChatRequest): Promise<Brow
// ===================== BROWSER-BACKED CHAT (DELEGATED) ===================== // ===================== BROWSER-BACKED CHAT (DELEGATED) =====================
export async function browserBackedChat(req: BrowserBackedChatRequest): Promise<BrowserBackedChatResult> { export async function browserBackedChat(
req: BrowserBackedChatRequest
): Promise<BrowserBackedChatResult> {
if (browserBackedChatOverride) return browserBackedChatOverride(req); if (browserBackedChatOverride) return browserBackedChatOverride(req);
const mod = await getMod(); const mod = await getMod();
if (!mod) throw new Error("Browser pool package not available"); if (!mod) throw new Error("Browser pool package not available");

View File

@@ -18,7 +18,8 @@
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@omniroute/open-sse": ["./open-sse"], "@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"] "include": ["**/*.ts", "**/*.js"]

112
package-lock.json generated
View File

@@ -10,7 +10,8 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"open-sse" "open-sse",
"packages/browser-pool"
], ],
"dependencies": { "dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1073.0", "@aws-sdk/client-bedrock-runtime": "^3.1073.0",
@@ -3709,9 +3710,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3728,9 +3726,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3747,9 +3742,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3766,9 +3758,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3785,9 +3774,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3804,9 +3790,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3823,9 +3806,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3842,9 +3822,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3861,9 +3838,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3886,9 +3860,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3911,9 +3882,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3936,9 +3904,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3961,9 +3926,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3986,9 +3948,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4011,9 +3970,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4036,9 +3992,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -6335,6 +6288,10 @@
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
} }
}, },
"node_modules/@omniroute/browser-pool": {
"resolved": "packages/browser-pool",
"link": true
},
"node_modules/@omniroute/open-sse": { "node_modules/@omniroute/open-sse": {
"resolved": "open-sse", "resolved": "open-sse",
"link": true "link": true
@@ -10723,9 +10680,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -10743,9 +10697,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -10763,9 +10714,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -10783,9 +10731,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -12878,9 +12823,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -12894,9 +12836,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -12910,9 +12849,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -12926,9 +12862,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -12942,9 +12875,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -12958,9 +12888,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -37032,6 +36959,33 @@
"safe-regex": "^2.1.1", "safe-regex": "^2.1.1",
"smol-toml": "1.7.0" "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"
} }
} }
} }

View File

@@ -47,7 +47,8 @@
"!**/*.spec.tsx" "!**/*.spec.tsx"
], ],
"workspaces": [ "workspaces": [
"open-sse" "open-sse",
"packages/browser-pool"
], ],
"engines": { "engines": {
"node": ">=22.22.2 <23 || >=24.0.0 <27" "node": ">=22.22.2 <23 || >=24.0.0 <27"

View 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"
}
}

View 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";

View 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;
}

View 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 */
}
}
}
}

View 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) };
}

View 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;
}
}

View 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"]
}

View File

@@ -1,4 +1,5 @@
packages: packages:
- "packages/*"
- "open-sse" - "open-sse"
allowBuilds: allowBuilds:
"@parcel/watcher": true "@parcel/watcher": true