mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
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)
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
112
package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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:
|
||||||
|
- "packages/*"
|
||||||
- "open-sse"
|
- "open-sse"
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
|
|||||||
Reference in New Issue
Block a user