@myrialabs/clopen 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/backend/index.ts +20 -0
- package/backend/lib/auth/auth-service.ts +484 -0
- package/backend/lib/auth/index.ts +4 -0
- package/backend/lib/auth/permissions.ts +63 -0
- package/backend/lib/auth/rate-limiter.ts +145 -0
- package/backend/lib/auth/tokens.ts +53 -0
- package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
- package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
- package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
- package/backend/lib/database/migrations/index.ts +21 -0
- package/backend/lib/database/queries/auth-queries.ts +201 -0
- package/backend/lib/database/queries/index.ts +2 -1
- package/backend/lib/engine/adapters/opencode/server.ts +1 -1
- package/backend/lib/mcp/config.ts +13 -18
- package/backend/lib/mcp/index.ts +9 -0
- package/backend/lib/mcp/remote-server.ts +132 -0
- package/backend/lib/mcp/servers/helper.ts +49 -3
- package/backend/lib/mcp/servers/index.ts +3 -2
- package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
- package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
- package/backend/lib/preview/browser/browser-pool.ts +73 -176
- package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
- package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
- package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
- package/backend/lib/utils/ws.ts +65 -1
- package/backend/ws/auth/index.ts +17 -0
- package/backend/ws/auth/invites.ts +84 -0
- package/backend/ws/auth/login.ts +269 -0
- package/backend/ws/auth/status.ts +41 -0
- package/backend/ws/auth/users.ts +32 -0
- package/backend/ws/engine/claude/accounts.ts +3 -1
- package/backend/ws/engine/utils.ts +38 -6
- package/backend/ws/index.ts +4 -4
- package/backend/ws/preview/browser/interact.ts +27 -5
- package/bin/clopen.ts +39 -0
- package/bun.lock +113 -51
- package/frontend/App.svelte +47 -29
- package/frontend/lib/components/auth/InvitePage.svelte +215 -0
- package/frontend/lib/components/auth/LoginPage.svelte +129 -0
- package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
- package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
- package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
- package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
- package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
- package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
- package/frontend/lib/components/settings/SettingsView.svelte +21 -7
- package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
- package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
- package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
- package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
- package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
- package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
- package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
- package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
- package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
- package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
- package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
- package/frontend/lib/stores/features/auth.svelte.ts +296 -0
- package/frontend/lib/stores/features/settings.svelte.ts +53 -9
- package/frontend/lib/stores/features/user.svelte.ts +26 -68
- package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
- package/frontend/lib/stores/ui/update.svelte.ts +2 -2
- package/package.json +8 -6
- package/shared/types/stores/settings.ts +16 -2
- package/shared/utils/logger.ts +1 -0
- package/shared/utils/ws-client.ts +30 -13
- package/shared/utils/ws-server.ts +42 -4
- package/backend/lib/mcp/stdio-server.ts +0 -103
- package/backend/ws/mcp/index.ts +0 -61
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote MCP HTTP Server for Open Code
|
|
3
|
+
*
|
|
4
|
+
* Serves custom MCP tools over HTTP (Streamable HTTP transport) so Open Code
|
|
5
|
+
* can connect via `type: 'remote'` config instead of spawning a stdio subprocess.
|
|
6
|
+
*
|
|
7
|
+
* Tool handlers execute directly in the main Clopen process — no subprocess,
|
|
8
|
+
* no WebSocket bridge. This is architecturally identical to how Claude Code
|
|
9
|
+
* uses in-process MCP servers via createSdkMcpServer().
|
|
10
|
+
*
|
|
11
|
+
* Transport: WebStandardStreamableHTTPServerTransport (works natively with Bun)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
15
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
16
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
|
+
import { createRemoteMcpServer } from './servers/helper';
|
|
18
|
+
import { debug } from '$shared/utils/logger';
|
|
19
|
+
|
|
20
|
+
// Lazy imports to avoid circular dependencies at module load time
|
|
21
|
+
let _allServers: Parameters<typeof createRemoteMcpServer>[0] | null = null;
|
|
22
|
+
let _enabledConfig: Parameters<typeof createRemoteMcpServer>[1] | null = null;
|
|
23
|
+
|
|
24
|
+
async function getServerDeps() {
|
|
25
|
+
if (!_allServers || !_enabledConfig) {
|
|
26
|
+
const { allServers } = await import('./servers/index');
|
|
27
|
+
const { mcpServersConfig } = await import('./config');
|
|
28
|
+
_allServers = allServers;
|
|
29
|
+
_enabledConfig = mcpServersConfig;
|
|
30
|
+
}
|
|
31
|
+
return { allServers: _allServers, enabledConfig: _enabledConfig };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Session Management
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/** Active transports keyed by MCP session ID */
|
|
39
|
+
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
|
40
|
+
|
|
41
|
+
/** Active MCP servers keyed by MCP session ID */
|
|
42
|
+
const servers = new Map<string, McpServer>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Handle an incoming MCP HTTP request (GET/POST/DELETE).
|
|
46
|
+
*
|
|
47
|
+
* Mounted at /mcp on the main Elysia server.
|
|
48
|
+
* Follows the Streamable HTTP transport protocol:
|
|
49
|
+
* - POST without session: initialization → create new transport + server
|
|
50
|
+
* - POST with session: route to existing transport
|
|
51
|
+
* - GET with session: SSE stream for server notifications
|
|
52
|
+
* - DELETE with session: close session
|
|
53
|
+
*/
|
|
54
|
+
export async function handleMcpRequest(request: Request): Promise<Response> {
|
|
55
|
+
const sessionId = request.headers.get('mcp-session-id');
|
|
56
|
+
|
|
57
|
+
// Existing session — route to its transport
|
|
58
|
+
if (sessionId && transports.has(sessionId)) {
|
|
59
|
+
const transport = transports.get(sessionId)!;
|
|
60
|
+
return transport.handleRequest(request);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// New initialization request — create transport + MCP server
|
|
64
|
+
if (request.method === 'POST') {
|
|
65
|
+
// Parse body to check if it's an init request
|
|
66
|
+
const body = await request.json();
|
|
67
|
+
|
|
68
|
+
if (isInitializeRequest(body)) {
|
|
69
|
+
const { allServers, enabledConfig } = await getServerDeps();
|
|
70
|
+
|
|
71
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
72
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
73
|
+
onsessioninitialized: (sid) => {
|
|
74
|
+
transports.set(sid, transport);
|
|
75
|
+
debug.log('mcp', `🌐 Remote MCP session initialized: ${sid}`);
|
|
76
|
+
},
|
|
77
|
+
onsessionclosed: (sid) => {
|
|
78
|
+
transports.delete(sid);
|
|
79
|
+
servers.delete(sid);
|
|
80
|
+
debug.log('mcp', `🌐 Remote MCP session closed: ${sid}`);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
transport.onclose = () => {
|
|
85
|
+
if (transport.sessionId) {
|
|
86
|
+
transports.delete(transport.sessionId);
|
|
87
|
+
servers.delete(transport.sessionId);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Create a fresh MCP server with all enabled tools (in-process handlers)
|
|
92
|
+
const mcpServer = createRemoteMcpServer(allServers, enabledConfig);
|
|
93
|
+
await mcpServer.connect(transport);
|
|
94
|
+
|
|
95
|
+
// Store server reference for cleanup
|
|
96
|
+
if (transport.sessionId) {
|
|
97
|
+
servers.set(transport.sessionId, mcpServer);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Handle the initialization request with pre-parsed body
|
|
101
|
+
return transport.handleRequest(request, { parsedBody: body });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Invalid request
|
|
106
|
+
return new Response(JSON.stringify({
|
|
107
|
+
jsonrpc: '2.0',
|
|
108
|
+
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
|
|
109
|
+
id: null,
|
|
110
|
+
}), {
|
|
111
|
+
status: 400,
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Close all active MCP sessions and transports.
|
|
118
|
+
* Called during graceful server shutdown.
|
|
119
|
+
*/
|
|
120
|
+
export async function closeMcpServer(): Promise<void> {
|
|
121
|
+
for (const [sessionId, transport] of transports) {
|
|
122
|
+
try {
|
|
123
|
+
await transport.close();
|
|
124
|
+
debug.log('mcp', `🌐 Remote MCP transport closed: ${sessionId}`);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
debug.error('mcp', `Error closing MCP transport ${sessionId}:`, error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
transports.clear();
|
|
130
|
+
servers.clear();
|
|
131
|
+
debug.log('mcp', '🌐 All remote MCP sessions closed');
|
|
132
|
+
}
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Stores both the Claude SDK server instance AND raw tool definitions
|
|
5
5
|
* so the same source can be used for Claude Code (in-process) and
|
|
6
|
-
* Open Code (
|
|
6
|
+
* Open Code (remote HTTP MCP via @modelcontextprotocol/sdk).
|
|
7
|
+
*
|
|
8
|
+
* Claude Code: createSdkMcpServer() → in-process MCP server
|
|
9
|
+
* Open Code: createRemoteMcpServer() → HTTP MCP server (same process, same handlers)
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
13
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
14
|
import type { z } from "zod";
|
|
11
15
|
|
|
12
16
|
/**
|
|
@@ -35,8 +39,7 @@ type ToolHandler<TSchema extends Record<string, z.ZodType<any>> | undefined> =
|
|
|
35
39
|
* Raw tool definition — schema, description, and handler.
|
|
36
40
|
* Single source of truth used by:
|
|
37
41
|
* - Claude Code: in-process via createSdkMcpServer
|
|
38
|
-
* - Open Code
|
|
39
|
-
* - MCP bridge: handler for in-process execution
|
|
42
|
+
* - Open Code: remote HTTP MCP via createRemoteMcpServer (in-process handlers)
|
|
40
43
|
*/
|
|
41
44
|
export interface RawToolDef {
|
|
42
45
|
description: string;
|
|
@@ -152,3 +155,46 @@ export function buildServerRegistries<
|
|
|
152
155
|
}
|
|
153
156
|
};
|
|
154
157
|
}
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Remote MCP Server for Open Code (HTTP transport, in-process execution)
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Create a McpServer instance (from @modelcontextprotocol/sdk) with tools registered
|
|
165
|
+
* from the same RawToolDef definitions used by Claude Code.
|
|
166
|
+
*
|
|
167
|
+
* This is the Open Code equivalent of createSdkMcpServer() for Claude Code.
|
|
168
|
+
* Handlers execute directly in-process — no subprocess, no bridge.
|
|
169
|
+
*
|
|
170
|
+
* @param servers - Server definitions from defineServer()
|
|
171
|
+
* @param enabledConfig - Which servers/tools are enabled (from mcpServersConfig)
|
|
172
|
+
*/
|
|
173
|
+
export function createRemoteMcpServer(
|
|
174
|
+
servers: readonly ServerWithMeta<string, readonly string[]>[],
|
|
175
|
+
enabledConfig: Record<string, { enabled: boolean; tools: readonly string[] }>
|
|
176
|
+
): McpServer {
|
|
177
|
+
const mcpServer = new McpServer({
|
|
178
|
+
name: 'clopen-mcp',
|
|
179
|
+
version: '1.0.0',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
for (const srv of servers) {
|
|
183
|
+
const config = enabledConfig[srv.meta.name];
|
|
184
|
+
if (!config?.enabled) continue;
|
|
185
|
+
|
|
186
|
+
for (const toolName of config.tools) {
|
|
187
|
+
const def = srv.meta.toolDefs[toolName as string];
|
|
188
|
+
if (!def) continue;
|
|
189
|
+
|
|
190
|
+
mcpServer.registerTool(toolName as string, {
|
|
191
|
+
description: def.description,
|
|
192
|
+
inputSchema: def.schema,
|
|
193
|
+
}, async (args: Record<string, unknown>) => {
|
|
194
|
+
return await def.handler(args) as any;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return mcpServer;
|
|
200
|
+
}
|
|
@@ -14,8 +14,9 @@ import weather from './weather/index';
|
|
|
14
14
|
import browserAutomation from './browser-automation/index';
|
|
15
15
|
import { buildServerRegistries } from './helper';
|
|
16
16
|
|
|
17
|
-
// Re-export types
|
|
17
|
+
// Re-export types and remote server factory
|
|
18
18
|
export type { RawToolDef } from './helper';
|
|
19
|
+
export { createRemoteMcpServer } from './helper';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* All MCP Servers
|
|
@@ -23,7 +24,7 @@ export type { RawToolDef } from './helper';
|
|
|
23
24
|
* Simply import and add new servers to this array.
|
|
24
25
|
* Metadata and registry will be automatically built.
|
|
25
26
|
*/
|
|
26
|
-
const allServers = [
|
|
27
|
+
export const allServers = [
|
|
27
28
|
weather,
|
|
28
29
|
browserAutomation,
|
|
29
30
|
// Add more servers here...
|
|
@@ -11,14 +11,31 @@ import { audioCaptureScript } from './scripts/audio-stream';
|
|
|
11
11
|
|
|
12
12
|
export class BrowserAudioCapture {
|
|
13
13
|
/**
|
|
14
|
-
* Setup audio capture for a page
|
|
15
|
-
*
|
|
14
|
+
* Setup audio capture for a page (pre-navigation).
|
|
15
|
+
* WARNING: Uses evaluateOnNewDocument which patches AudioContext BEFORE page
|
|
16
|
+
* scripts run. This is detected by Cloudflare's fingerprinting.
|
|
17
|
+
* Prefer injectAudioCapture() for post-navigation injection.
|
|
16
18
|
*/
|
|
17
19
|
async setupAudioCapture(page: Page, config: StreamingConfig['audio']): Promise<void> {
|
|
18
|
-
// Inject audio capture script BEFORE page loads to intercept AudioContext
|
|
19
20
|
await page.evaluateOnNewDocument(audioCaptureScript, config);
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Inject audio capture into the current page context (post-navigation).
|
|
25
|
+
* Uses page.evaluate() instead of evaluateOnNewDocument() to avoid
|
|
26
|
+
* Cloudflare detection — AudioContext constructor patching before page
|
|
27
|
+
* load is heavily flagged by CF's fingerprinting algorithms.
|
|
28
|
+
* Call this AFTER navigation completes and CF challenges pass.
|
|
29
|
+
*/
|
|
30
|
+
async injectAudioCapture(page: Page, config: StreamingConfig['audio']): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
await page.evaluate(audioCaptureScript, config);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
40
|
* Check if audio encoder is supported in the page
|
|
24
41
|
*/
|
|
@@ -62,6 +62,8 @@ export class BrowserNavigationTracker extends EventEmitter {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
// Track hash changes (fragment identifier changes like #contact-us)
|
|
65
|
+
// Temporarily disabled URL tracking injection to test CloudFlare evasion
|
|
66
|
+
/*
|
|
65
67
|
await page.evaluateOnNewDocument(() => {
|
|
66
68
|
let lastUrl = window.location.href;
|
|
67
69
|
|
|
@@ -86,6 +88,7 @@ export class BrowserNavigationTracker extends EventEmitter {
|
|
|
86
88
|
// Periodically check for URL changes (for SPA navigation)
|
|
87
89
|
setInterval(checkUrlChange, 500);
|
|
88
90
|
});
|
|
91
|
+
*/
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
async navigateSession(sessionId: string, session: BrowserTab, url: string): Promise<string> {
|
|
@@ -1,35 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser Pool Module
|
|
2
|
+
* Browser Pool Module
|
|
3
3
|
*
|
|
4
|
-
* Uses puppeteer-
|
|
4
|
+
* Uses puppeteer-extra with StealthPlugin for Cloudflare bypass.
|
|
5
|
+
* Architecture mirrors the working test-cf.ts approach:
|
|
6
|
+
* - Single shared browser launched directly via puppeteer.launch()
|
|
7
|
+
* - Isolated BrowserContext per session (separate cookies, storage, cache)
|
|
8
|
+
* - StealthPlugin applied at launch time (via puppeteer-extra hooks)
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - Separate cookies
|
|
13
|
-
* - Separate localStorage/sessionStorage
|
|
14
|
-
* - Separate cache
|
|
15
|
-
* - Separate service workers
|
|
16
|
-
* - No data leakage between users
|
|
17
|
-
*
|
|
18
|
-
* Memory Usage:
|
|
19
|
-
* - 1 user: ~300MB (browser) + ~20MB (context) = ~320MB
|
|
20
|
-
* - 10 users: ~300MB (browser) + ~200MB (contexts) = ~500MB
|
|
21
|
-
* - vs. old: 10 users = 10 browsers = 2-5GB
|
|
10
|
+
* Why not puppeteer-cluster?
|
|
11
|
+
* - Cluster's CONCURRENCY_CONTEXT mode accesses the browser via the raw
|
|
12
|
+
* underlying reference, bypassing puppeteer-extra's page creation hooks.
|
|
13
|
+
* - This causes a race condition where stealth evasions (evaluateOnNewDocument)
|
|
14
|
+
* may not be registered before the first navigation, breaking Cloudflare bypass.
|
|
15
|
+
* - Direct launch() ensures puppeteer-extra wraps ALL page creation correctly.
|
|
22
16
|
*/
|
|
23
17
|
|
|
24
|
-
import { Cluster } from 'puppeteer-cluster';
|
|
25
18
|
import type { Browser, BrowserContext, Page } from 'puppeteer';
|
|
26
19
|
import { debug } from '$shared/utils/logger';
|
|
20
|
+
import puppeteer from 'puppeteer-extra';
|
|
21
|
+
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
|
22
|
+
|
|
23
|
+
puppeteer.use(StealthPlugin());
|
|
27
24
|
|
|
28
25
|
export interface PoolConfig {
|
|
29
|
-
maxConcurrency: number;
|
|
30
|
-
timeout: number;
|
|
31
|
-
retryLimit: number;
|
|
32
|
-
retryDelay: number;
|
|
26
|
+
maxConcurrency: number;
|
|
27
|
+
timeout: number;
|
|
28
|
+
retryLimit: number;
|
|
29
|
+
retryDelay: number;
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
export interface PooledSession {
|
|
@@ -40,150 +37,89 @@ export interface PooledSession {
|
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
const DEFAULT_CONFIG: PoolConfig = {
|
|
43
|
-
maxConcurrency: 50,
|
|
44
|
-
timeout: 60000,
|
|
45
|
-
retryLimit: 3,
|
|
46
|
-
retryDelay: 1000
|
|
40
|
+
maxConcurrency: 50,
|
|
41
|
+
timeout: 60000,
|
|
42
|
+
retryLimit: 3,
|
|
43
|
+
retryDelay: 1000
|
|
47
44
|
};
|
|
48
45
|
|
|
49
46
|
/**
|
|
50
|
-
*
|
|
47
|
+
* Chromium launch arguments for stealth (matches test-cf.ts exactly)
|
|
51
48
|
*/
|
|
52
49
|
const CHROMIUM_ARGS = [
|
|
53
|
-
// === CORE STABILITY (Windows compatible) ===
|
|
54
50
|
'--no-sandbox',
|
|
55
|
-
'--disable-
|
|
56
|
-
'--
|
|
57
|
-
|
|
58
|
-
// === PREVENT THROTTLING ===
|
|
59
|
-
'--disable-background-timer-throttling',
|
|
60
|
-
'--disable-backgrounding-occluded-windows',
|
|
61
|
-
'--disable-renderer-backgrounding',
|
|
62
|
-
|
|
63
|
-
// === DISABLE UNNECESSARY FEATURES ===
|
|
64
|
-
'--no-first-run',
|
|
65
|
-
'--no-default-browser-check',
|
|
66
|
-
'--disable-extensions',
|
|
67
|
-
'--disable-popup-blocking',
|
|
68
|
-
|
|
69
|
-
// === LOW-END DEVICE OPTIMIZATIONS ===
|
|
70
|
-
'--memory-pressure-off',
|
|
71
|
-
'--disable-features=TranslateUI',
|
|
72
|
-
'--disable-sync',
|
|
73
|
-
'--disable-domain-reliability',
|
|
74
|
-
'--disable-client-side-phishing-detection',
|
|
75
|
-
'--disable-software-rasterizer',
|
|
76
|
-
'--disable-smooth-scrolling',
|
|
77
|
-
'--disable-threaded-animation',
|
|
78
|
-
'--disable-threaded-scrolling',
|
|
79
|
-
'--disable-composited-antialiasing',
|
|
80
|
-
'--disable-webgl',
|
|
81
|
-
'--disable-webgl2',
|
|
82
|
-
'--disable-accelerated-2d-canvas',
|
|
83
|
-
'--disable-gpu-vsync',
|
|
84
|
-
'--disable-ipc-flooding-protection',
|
|
85
|
-
|
|
86
|
-
// === AUDIO SUPPORT ===
|
|
87
|
-
'--autoplay-policy=no-user-gesture-required',
|
|
88
|
-
'--use-fake-ui-for-media-stream'
|
|
51
|
+
'--disable-blink-features=AutomationControlled',
|
|
52
|
+
'--window-size=1366,768'
|
|
89
53
|
];
|
|
90
54
|
|
|
91
55
|
class BrowserPool {
|
|
92
|
-
private
|
|
56
|
+
private browser: Browser | null = null;
|
|
93
57
|
private sessions = new Map<string, PooledSession>();
|
|
94
58
|
private config: PoolConfig;
|
|
95
|
-
private
|
|
96
|
-
private
|
|
59
|
+
private isLaunching = false;
|
|
60
|
+
private launchPromise: Promise<Browser> | null = null;
|
|
97
61
|
|
|
98
62
|
constructor(config: Partial<PoolConfig> = {}) {
|
|
99
63
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
100
64
|
}
|
|
101
65
|
|
|
102
66
|
/**
|
|
103
|
-
* Get or create the
|
|
104
|
-
*
|
|
67
|
+
* Get or create the shared browser instance.
|
|
68
|
+
* Uses puppeteer-extra directly (same as test-cf.ts) to ensure
|
|
69
|
+
* StealthPlugin hooks fire for every page created.
|
|
105
70
|
*/
|
|
106
|
-
async
|
|
107
|
-
if (this.
|
|
108
|
-
return this.
|
|
71
|
+
async getBrowser(): Promise<Browser> {
|
|
72
|
+
if (this.browser?.connected) {
|
|
73
|
+
return this.browser;
|
|
109
74
|
}
|
|
110
75
|
|
|
111
|
-
if (this.
|
|
112
|
-
return this.
|
|
76
|
+
if (this.isLaunching && this.launchPromise) {
|
|
77
|
+
return this.launchPromise;
|
|
113
78
|
}
|
|
114
79
|
|
|
115
|
-
this.
|
|
116
|
-
this.
|
|
80
|
+
this.isLaunching = true;
|
|
81
|
+
this.launchPromise = this.launchBrowser();
|
|
117
82
|
|
|
118
83
|
try {
|
|
119
|
-
this.
|
|
120
|
-
return this.
|
|
84
|
+
this.browser = await this.launchPromise;
|
|
85
|
+
return this.browser;
|
|
121
86
|
} finally {
|
|
122
|
-
this.
|
|
123
|
-
this.
|
|
87
|
+
this.isLaunching = false;
|
|
88
|
+
this.launchPromise = null;
|
|
124
89
|
}
|
|
125
90
|
}
|
|
126
91
|
|
|
127
92
|
/**
|
|
128
|
-
* Launch puppeteer-
|
|
93
|
+
* Launch browser via puppeteer-extra (with StealthPlugin already registered).
|
|
94
|
+
* This matches test-cf.ts which successfully bypasses Cloudflare.
|
|
129
95
|
*/
|
|
130
|
-
private async
|
|
131
|
-
debug.log('preview', '🚀 Launching puppeteer-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// Monitor events
|
|
148
|
-
monitor: false // Disable built-in monitoring, we use our own logging
|
|
96
|
+
private async launchBrowser(): Promise<Browser> {
|
|
97
|
+
debug.log('preview', '🚀 Launching browser with puppeteer-extra + StealthPlugin...');
|
|
98
|
+
|
|
99
|
+
const browser = await puppeteer.launch({
|
|
100
|
+
headless: true,
|
|
101
|
+
channel: 'chrome',
|
|
102
|
+
args: CHROMIUM_ARGS
|
|
103
|
+
}) as unknown as Browser;
|
|
104
|
+
|
|
105
|
+
debug.log('preview', '✅ Browser launched successfully');
|
|
106
|
+
|
|
107
|
+
// Handle browser disconnection
|
|
108
|
+
browser.on('disconnected', () => {
|
|
109
|
+
debug.warn('preview', '⚠️ Browser disconnected');
|
|
110
|
+
this.browser = null;
|
|
111
|
+
// Close all sessions since browser is gone
|
|
112
|
+
this.sessions.clear();
|
|
149
113
|
});
|
|
150
114
|
|
|
151
|
-
|
|
152
|
-
cluster.on('taskerror', (err, data) => {
|
|
153
|
-
debug.error('preview', `Task error for session ${data?.sessionId}:`, err.message);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
debug.log('preview', '✅ puppeteer-cluster launched successfully');
|
|
157
|
-
debug.log('preview', `📊 Max concurrency: ${this.config.maxConcurrency}`);
|
|
158
|
-
|
|
159
|
-
return cluster;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Get the shared browser instance from the cluster
|
|
164
|
-
* Note: This accesses the internal browser - use with caution
|
|
165
|
-
*/
|
|
166
|
-
async getBrowser(): Promise<Browser> {
|
|
167
|
-
const cluster = await this.getCluster();
|
|
168
|
-
|
|
169
|
-
// Access the browser through a dummy task
|
|
170
|
-
// This is a workaround since cluster doesn't expose browser directly
|
|
171
|
-
return new Promise((resolve, reject) => {
|
|
172
|
-
cluster
|
|
173
|
-
.execute(async ({ page }: { page: Page }) => {
|
|
174
|
-
const browser = page.browser();
|
|
175
|
-
resolve(browser);
|
|
176
|
-
})
|
|
177
|
-
.catch(reject);
|
|
178
|
-
});
|
|
115
|
+
return browser;
|
|
179
116
|
}
|
|
180
117
|
|
|
181
118
|
/**
|
|
182
|
-
* Create an isolated session with its own BrowserContext
|
|
183
|
-
*
|
|
119
|
+
* Create an isolated session with its own BrowserContext.
|
|
120
|
+
* Each context has separate cookies, localStorage, sessionStorage, and cache.
|
|
184
121
|
*/
|
|
185
122
|
async createSession(sessionId: string): Promise<PooledSession> {
|
|
186
|
-
// Check if session already exists
|
|
187
123
|
const existing = this.sessions.get(sessionId);
|
|
188
124
|
if (existing) {
|
|
189
125
|
debug.log('preview', `♻️ Reusing existing session: ${sessionId}`);
|
|
@@ -192,13 +128,10 @@ class BrowserPool {
|
|
|
192
128
|
|
|
193
129
|
debug.log('preview', `🔒 Creating isolated session: ${sessionId}`);
|
|
194
130
|
|
|
195
|
-
const cluster = await this.getCluster();
|
|
196
131
|
const browser = await this.getBrowser();
|
|
197
132
|
|
|
198
|
-
// Create isolated
|
|
199
|
-
//
|
|
200
|
-
// When sessionId is prefixed with projectId (e.g., "project123:tab-1"),
|
|
201
|
-
// this ensures complete isolation between projects
|
|
133
|
+
// Create isolated context — puppeteer-extra wraps this correctly
|
|
134
|
+
// so StealthPlugin's onPageCreated fires for every page in this context
|
|
202
135
|
const context = await browser.createBrowserContext();
|
|
203
136
|
const page = await context.newPage();
|
|
204
137
|
|
|
@@ -242,14 +175,12 @@ class BrowserPool {
|
|
|
242
175
|
debug.log('preview', `🗑️ Destroying session: ${sessionId}`);
|
|
243
176
|
|
|
244
177
|
try {
|
|
245
|
-
// Close the page first
|
|
246
178
|
if (session.page && !session.page.isClosed()) {
|
|
247
179
|
await session.page.close().catch((err: Error) => {
|
|
248
180
|
debug.warn('preview', `Error closing page: ${err.message}`);
|
|
249
181
|
});
|
|
250
182
|
}
|
|
251
183
|
|
|
252
|
-
// Close the context (this clears all cookies, storage, cache)
|
|
253
184
|
await session.context.close().catch((err: Error) => {
|
|
254
185
|
debug.warn('preview', `Error closing context: ${err.message}`);
|
|
255
186
|
});
|
|
@@ -261,35 +192,13 @@ class BrowserPool {
|
|
|
261
192
|
debug.log('preview', `✅ Session destroyed (remaining: ${this.sessions.size})`);
|
|
262
193
|
}
|
|
263
194
|
|
|
264
|
-
/**
|
|
265
|
-
* Execute a task in the cluster (for one-off operations)
|
|
266
|
-
*/
|
|
267
|
-
async execute<T>(
|
|
268
|
-
taskFunction: (opts: { page: Page; data: any }) => Promise<T>,
|
|
269
|
-
data?: any
|
|
270
|
-
): Promise<T> {
|
|
271
|
-
const cluster = await this.getCluster();
|
|
272
|
-
return cluster.execute(data, taskFunction);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Queue a task in the cluster
|
|
277
|
-
*/
|
|
278
|
-
async queue(taskFunction: (opts: { page: Page; data: any }) => Promise<void>, data?: any): Promise<void> {
|
|
279
|
-
const cluster = await this.getCluster();
|
|
280
|
-
cluster.queue(data, taskFunction);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
195
|
/**
|
|
284
196
|
* Check if a session is valid
|
|
285
197
|
*/
|
|
286
198
|
isSessionValid(sessionId: string): boolean {
|
|
287
199
|
const session = this.sessions.get(sessionId);
|
|
288
200
|
if (!session) return false;
|
|
289
|
-
|
|
290
|
-
// Check if page is still open
|
|
291
201
|
if (session.page.isClosed()) return false;
|
|
292
|
-
|
|
293
202
|
return true;
|
|
294
203
|
}
|
|
295
204
|
|
|
@@ -298,7 +207,7 @@ class BrowserPool {
|
|
|
298
207
|
*/
|
|
299
208
|
getStats() {
|
|
300
209
|
return {
|
|
301
|
-
|
|
210
|
+
browserConnected: this.browser?.connected ?? false,
|
|
302
211
|
activeSessions: this.sessions.size,
|
|
303
212
|
maxConcurrency: this.config.maxConcurrency,
|
|
304
213
|
sessions: Array.from(this.sessions.entries()).map(([id, session]) => ({
|
|
@@ -310,34 +219,22 @@ class BrowserPool {
|
|
|
310
219
|
};
|
|
311
220
|
}
|
|
312
221
|
|
|
313
|
-
/**
|
|
314
|
-
* Wait for all queued tasks to complete
|
|
315
|
-
*/
|
|
316
|
-
async idle(): Promise<void> {
|
|
317
|
-
if (this.cluster) {
|
|
318
|
-
await this.cluster.idle();
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
222
|
/**
|
|
323
223
|
* Clean up all resources
|
|
324
224
|
*/
|
|
325
225
|
async cleanup(): Promise<void> {
|
|
326
226
|
debug.log('preview', '🧹 Cleaning up browser pool...');
|
|
327
227
|
|
|
328
|
-
// Destroy all sessions
|
|
329
228
|
const sessionIds = Array.from(this.sessions.keys());
|
|
330
229
|
await Promise.all(sessionIds.map((id) => this.destroySession(id)));
|
|
331
230
|
|
|
332
|
-
|
|
333
|
-
if (this.cluster) {
|
|
231
|
+
if (this.browser) {
|
|
334
232
|
try {
|
|
335
|
-
await this.
|
|
336
|
-
await this.cluster.close();
|
|
233
|
+
await this.browser.close();
|
|
337
234
|
} catch (error) {
|
|
338
|
-
debug.warn('preview', `⚠️ Error closing
|
|
235
|
+
debug.warn('preview', `⚠️ Error closing browser: ${error}`);
|
|
339
236
|
}
|
|
340
|
-
this.
|
|
237
|
+
this.browser = null;
|
|
341
238
|
}
|
|
342
239
|
|
|
343
240
|
debug.log('preview', '✅ Browser pool cleaned up');
|
|
@@ -219,8 +219,9 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
219
219
|
await this.navigationTracker.setupNavigationTracking(tab.id, tab.page, tab);
|
|
220
220
|
|
|
221
221
|
// Setup dialog bindings and handling
|
|
222
|
-
|
|
223
|
-
await this.dialogHandler.
|
|
222
|
+
// Temporarily disable dialog injection to test CloudFlare evasion
|
|
223
|
+
// await this.dialogHandler.setupDialogBindings(tab.id, tab.page);
|
|
224
|
+
// await this.dialogHandler.setupDialogHandling(tab.id, tab.page, tab);
|
|
224
225
|
|
|
225
226
|
return tab;
|
|
226
227
|
}
|