@phi-code-admin/camofox-browser 1.0.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/AGENTS.md +571 -0
- package/Dockerfile +86 -0
- package/LICENSE +21 -0
- package/README.md +691 -0
- package/camofox.config.json +10 -0
- package/dist/plugin.js +616 -0
- package/lib/auth.js +134 -0
- package/lib/camoufox-executable.js +189 -0
- package/lib/config.js +153 -0
- package/lib/cookies.js +119 -0
- package/lib/downloads.js +168 -0
- package/lib/extract.js +74 -0
- package/lib/fly.js +54 -0
- package/lib/images.js +88 -0
- package/lib/inflight.js +16 -0
- package/lib/launcher.js +47 -0
- package/lib/macros.js +31 -0
- package/lib/metrics.js +184 -0
- package/lib/openapi.js +105 -0
- package/lib/persistence.js +89 -0
- package/lib/plugins.js +175 -0
- package/lib/proxy.js +277 -0
- package/lib/reporter.js +1102 -0
- package/lib/request-utils.js +59 -0
- package/lib/resources.js +76 -0
- package/lib/snapshot.js +41 -0
- package/lib/tmp-cleanup.js +108 -0
- package/lib/tracing.js +137 -0
- package/openclaw.plugin.json +268 -0
- package/package.json +148 -0
- package/plugin.js +616 -0
- package/plugin.ts +758 -0
- package/plugins/persistence/AGENTS.md +37 -0
- package/plugins/persistence/README.md +48 -0
- package/plugins/persistence/index.js +124 -0
- package/plugins/vnc/AGENTS.md +42 -0
- package/plugins/vnc/README.md +165 -0
- package/plugins/vnc/apt.txt +7 -0
- package/plugins/vnc/index.js +142 -0
- package/plugins/vnc/spawn.js +8 -0
- package/plugins/vnc/vnc-launcher.js +64 -0
- package/plugins/vnc/vnc-watcher.sh +82 -0
- package/plugins/youtube/AGENTS.md +25 -0
- package/plugins/youtube/apt.txt +1 -0
- package/plugins/youtube/index.js +206 -0
- package/plugins/youtube/post-install.sh +5 -0
- package/plugins/youtube/youtube.js +301 -0
- package/run.sh +37 -0
- package/scripts/exec.js +8 -0
- package/scripts/generate-openapi.js +24 -0
- package/scripts/install-plugin-deps.sh +63 -0
- package/scripts/plugin.js +342 -0
- package/scripts/postinstall.js +20 -0
- package/scripts/sync-version.js +25 -0
- package/server.js +6059 -0
- package/tsconfig.json +12 -0
package/lib/openapi.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI spec generation via swagger-jsdoc + docs UI (swagger-stripey).
|
|
3
|
+
*
|
|
4
|
+
* swagger-jsdoc scans JSDoc `@openapi` comments on route handlers in server.js
|
|
5
|
+
* (and any file passed in `apis`) to build the spec at startup.
|
|
6
|
+
* Docs UI lives in docs/api.html (swagger-stripey: Stripe-style 3-panel renderer).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { mountDocs } from './lib/openapi.js';
|
|
10
|
+
* // After all routes are registered:
|
|
11
|
+
* mountDocs(app);
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import swaggerJsdoc from 'swagger-jsdoc';
|
|
15
|
+
import express from 'express';
|
|
16
|
+
import { readFileSync } from 'fs';
|
|
17
|
+
import { dirname, join } from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
let version = 'unknown';
|
|
23
|
+
try {
|
|
24
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
25
|
+
version = pkg.version;
|
|
26
|
+
} catch { /* ignore */ }
|
|
27
|
+
|
|
28
|
+
const swaggerDefinition = {
|
|
29
|
+
openapi: '3.0.3',
|
|
30
|
+
info: {
|
|
31
|
+
title: 'camofox-browser',
|
|
32
|
+
version,
|
|
33
|
+
description:
|
|
34
|
+
'Anti-detection browser automation server for AI agents. ' +
|
|
35
|
+
'Accessibility snapshots, element refs, session isolation, cookie import, proxy rotation, and structured logs.',
|
|
36
|
+
license: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' },
|
|
37
|
+
contact: { name: 'Jo Inc', url: 'https://askjo.ai', email: 'oss@askjo.ai' },
|
|
38
|
+
},
|
|
39
|
+
servers: [{ url: 'http://localhost:9377', description: 'Local development' }],
|
|
40
|
+
tags: [
|
|
41
|
+
{ name: 'System', description: 'Server health, metrics, and status.' },
|
|
42
|
+
{ name: 'Tabs', description: 'Create, list, inspect, and destroy browser tabs.' },
|
|
43
|
+
{ name: 'Navigation', description: 'Navigate tabs to URLs or via search macros.' },
|
|
44
|
+
{ name: 'Interaction', description: 'Click, type, scroll, press keys, evaluate JS.' },
|
|
45
|
+
{ name: 'Content', description: 'Accessibility snapshots, screenshots, links, images, downloads.' },
|
|
46
|
+
{ name: 'Sessions', description: 'Per-user session state: cookies, teardown.' },
|
|
47
|
+
{ name: 'Browser', description: 'Global browser lifecycle (start/stop).' },
|
|
48
|
+
{ name: 'Legacy', description: 'OpenClaw-compatible endpoints (deprecated).' },
|
|
49
|
+
],
|
|
50
|
+
components: {
|
|
51
|
+
securitySchemes: {
|
|
52
|
+
BearerAuth: {
|
|
53
|
+
type: 'http',
|
|
54
|
+
scheme: 'bearer',
|
|
55
|
+
description: 'Bearer token matching CAMOFOX_API_KEY (per-route auth for sensitive endpoints like cookie import and traces).',
|
|
56
|
+
},
|
|
57
|
+
AccessKeyAuth: {
|
|
58
|
+
type: 'http',
|
|
59
|
+
scheme: 'bearer',
|
|
60
|
+
description: 'Bearer token matching CAMOFOX_ACCESS_KEY. When set, gates all routes except /health, cookie import, and /stop. Acts as a superkey -- also accepted by endpoints that normally require CAMOFOX_API_KEY.',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
schemas: {
|
|
64
|
+
Error: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
required: ['error'],
|
|
67
|
+
properties: { error: { type: 'string' } },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mount GET /openapi.json and GET /docs on the Express app.
|
|
75
|
+
* Call AFTER all routes are registered so swagger-jsdoc can scan them.
|
|
76
|
+
*
|
|
77
|
+
* @param {import('express').Application} app
|
|
78
|
+
* @param {Object} [opts]
|
|
79
|
+
* @param {string[]} [opts.apis] - Glob patterns for files with @openapi JSDoc (default: ['./server.js'])
|
|
80
|
+
*/
|
|
81
|
+
export function mountDocs(app, opts = {}) {
|
|
82
|
+
const apis = opts.apis || ['./server.js'];
|
|
83
|
+
|
|
84
|
+
const spec = swaggerJsdoc({
|
|
85
|
+
definition: swaggerDefinition,
|
|
86
|
+
apis,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
app.get('/openapi.json', (_req, res) => {
|
|
90
|
+
res.json(spec);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Serve docs static assets (api.html, fox.png, openapi.json)
|
|
94
|
+
const docsDir = join(__dirname, '..', 'docs');
|
|
95
|
+
app.use('/docs', express.static(docsDir, { index: 'api.html' }));
|
|
96
|
+
|
|
97
|
+
// Also serve fox.png at root for backward compat with old Swagger UI HTML
|
|
98
|
+
app.get('/fox.png', (_req, res) => {
|
|
99
|
+
res.sendFile(join(docsDir, 'fox.png'));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return spec;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { swaggerDefinition };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function getUserPersistencePaths(profileDir, userId) {
|
|
6
|
+
const rootDir = path.resolve(profileDir);
|
|
7
|
+
const safeUserDir = crypto
|
|
8
|
+
.createHash('sha256')
|
|
9
|
+
.update(String(userId))
|
|
10
|
+
.digest('hex')
|
|
11
|
+
.slice(0, 32);
|
|
12
|
+
|
|
13
|
+
const userDir = path.join(rootDir, safeUserDir);
|
|
14
|
+
return {
|
|
15
|
+
rootDir,
|
|
16
|
+
userDir,
|
|
17
|
+
storageStatePath: path.join(userDir, 'storage-state.json'),
|
|
18
|
+
metaPath: path.join(userDir, 'meta.json'),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function loadPersistedStorageState(profileDir, userId, logger = console) {
|
|
23
|
+
if (!profileDir) return undefined;
|
|
24
|
+
|
|
25
|
+
const { storageStatePath } = getUserPersistencePaths(profileDir, userId);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const raw = await fs.readFile(storageStatePath, 'utf8');
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
if (!parsed || typeof parsed !== 'object') return undefined;
|
|
31
|
+
if (!Array.isArray(parsed.cookies)) return undefined;
|
|
32
|
+
if (parsed.origins !== undefined && !Array.isArray(parsed.origins)) return undefined;
|
|
33
|
+
return storageStatePath;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err?.code === 'ENOENT') return undefined;
|
|
36
|
+
logger?.warn?.('failed to load persisted storage state', {
|
|
37
|
+
userId: String(userId),
|
|
38
|
+
storageStatePath,
|
|
39
|
+
error: err?.message || String(err),
|
|
40
|
+
});
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function persistStorageState({ profileDir, userId, context, logger = console }) {
|
|
46
|
+
if (!profileDir || !context) {
|
|
47
|
+
return { persisted: false, reason: 'disabled' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { userDir, storageStatePath, metaPath } = getUserPersistencePaths(profileDir, userId);
|
|
51
|
+
const suffix = `.tmp-${process.pid}-${Date.now()}`;
|
|
52
|
+
const tmpStoragePath = `${storageStatePath}${suffix}`;
|
|
53
|
+
const tmpMetaPath = `${metaPath}${suffix}`;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await fs.mkdir(userDir, { recursive: true });
|
|
57
|
+
await context.storageState({ path: tmpStoragePath });
|
|
58
|
+
await fs.rename(tmpStoragePath, storageStatePath);
|
|
59
|
+
await fs.writeFile(
|
|
60
|
+
tmpMetaPath,
|
|
61
|
+
JSON.stringify(
|
|
62
|
+
{
|
|
63
|
+
userId: String(userId),
|
|
64
|
+
updatedAt: new Date().toISOString(),
|
|
65
|
+
storageStatePath,
|
|
66
|
+
},
|
|
67
|
+
null,
|
|
68
|
+
2
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
await fs.rename(tmpMetaPath, metaPath);
|
|
72
|
+
return { persisted: true, userDir, storageStatePath, metaPath };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
await fs.unlink(tmpStoragePath).catch(() => {});
|
|
75
|
+
await fs.unlink(tmpMetaPath).catch(() => {});
|
|
76
|
+
logger?.warn?.('failed to persist storage state', {
|
|
77
|
+
userId: String(userId),
|
|
78
|
+
storageStatePath,
|
|
79
|
+
error: err?.message || String(err),
|
|
80
|
+
});
|
|
81
|
+
return { persisted: false, reason: 'error', error: err };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export {
|
|
86
|
+
getUserPersistencePaths,
|
|
87
|
+
loadPersistedStorageState,
|
|
88
|
+
persistStorageState,
|
|
89
|
+
};
|
package/lib/plugins.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Camofox-browser plugin system.
|
|
3
|
+
*
|
|
4
|
+
* Plugins live in plugins/<name>/index.js and export a register(app, ctx) function.
|
|
5
|
+
* The ctx object provides access to sessions, config, logging, auth middleware,
|
|
6
|
+
* core functions, and an EventEmitter for lifecycle hooks.
|
|
7
|
+
*
|
|
8
|
+
* 29 events across 7 categories:
|
|
9
|
+
*
|
|
10
|
+
* BROWSER LIFECYCLE
|
|
11
|
+
* browser:launching { options } -- mutate launch options
|
|
12
|
+
* browser:launched { browser, display } -- after launch
|
|
13
|
+
* browser:restart { reason } -- before restart cycle
|
|
14
|
+
* browser:closed { reason } -- after browser closed
|
|
15
|
+
* browser:error { error } -- uncaught browser error
|
|
16
|
+
*
|
|
17
|
+
* SESSION LIFECYCLE
|
|
18
|
+
* session:creating { userId, contextOptions } -- mutate context options
|
|
19
|
+
* session:created { userId, context } -- after context stored
|
|
20
|
+
* session:destroying { userId, reason } -- before context close (context still alive)
|
|
21
|
+
* session:destroyed { userId, reason } -- after cleanup
|
|
22
|
+
* session:expired { userId, idleMs } -- reaper triggered
|
|
23
|
+
*
|
|
24
|
+
* TAB LIFECYCLE
|
|
25
|
+
* tab:created { userId, tabId, page, url }
|
|
26
|
+
* tab:navigated { userId, tabId, url, prevUrl }
|
|
27
|
+
* tab:destroyed { userId, tabId, reason }
|
|
28
|
+
* tab:recycled { userId, tabId }
|
|
29
|
+
* tab:error { userId, tabId, error }
|
|
30
|
+
*
|
|
31
|
+
* CONTENT
|
|
32
|
+
* tab:snapshot { userId, tabId, snapshot }
|
|
33
|
+
* tab:screenshot { userId, tabId, buffer }
|
|
34
|
+
* tab:evaluate { userId, tabId, expression }
|
|
35
|
+
* tab:evaluated { userId, tabId, result }
|
|
36
|
+
*
|
|
37
|
+
* INPUT
|
|
38
|
+
* tab:click { userId, tabId, ref, selector }
|
|
39
|
+
* tab:type { userId, tabId, text, ref, mode }
|
|
40
|
+
* tab:scroll { userId, tabId, direction, amount }
|
|
41
|
+
* tab:press { userId, tabId, key }
|
|
42
|
+
*
|
|
43
|
+
* DOWNLOADS
|
|
44
|
+
* tab:download:start { userId, tabId, filename, url }
|
|
45
|
+
* tab:download:complete { userId, tabId, filename, path, size }
|
|
46
|
+
*
|
|
47
|
+
* COOKIES / AUTH
|
|
48
|
+
* session:cookies:import { userId, count }
|
|
49
|
+
* session:storage:export { userId }
|
|
50
|
+
*
|
|
51
|
+
* SERVER
|
|
52
|
+
* server:starting { port }
|
|
53
|
+
* server:started { port, pid }
|
|
54
|
+
* server:shutdown { signal }
|
|
55
|
+
*
|
|
56
|
+
* Mutating hooks (browser:launching, session:creating) pass the options object
|
|
57
|
+
* by reference -- plugins can modify it in place before core uses it.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { EventEmitter } from 'events';
|
|
61
|
+
import fs from 'fs';
|
|
62
|
+
import path from 'path';
|
|
63
|
+
import { fileURLToPath } from 'url';
|
|
64
|
+
|
|
65
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
const ROOT_DIR = path.join(__dirname, '..');
|
|
67
|
+
const PLUGINS_DIR = path.join(ROOT_DIR, 'plugins');
|
|
68
|
+
const CONFIG_PATH = path.join(ROOT_DIR, 'camofox.config.json');
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read plugin configuration from camofox.config.json.
|
|
72
|
+
* Supports two formats:
|
|
73
|
+
* - Array of strings: ["youtube", "persistence"] (no per-plugin config)
|
|
74
|
+
* - Object with per-plugin config: { "youtube": { "enabled": true }, "persistence": { "enabled": true, "profileDir": "/data" } }
|
|
75
|
+
* Returns { list: string[] | null, configs: Map<string, object> }
|
|
76
|
+
*/
|
|
77
|
+
function readPluginConfig() {
|
|
78
|
+
const configs = new Map();
|
|
79
|
+
try {
|
|
80
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
81
|
+
const config = JSON.parse(raw);
|
|
82
|
+
if (!config.plugins) return { list: null, configs };
|
|
83
|
+
if (Array.isArray(config.plugins)) {
|
|
84
|
+
return { list: config.plugins, configs };
|
|
85
|
+
}
|
|
86
|
+
if (typeof config.plugins === 'object') {
|
|
87
|
+
const list = [];
|
|
88
|
+
for (const [name, pluginConf] of Object.entries(config.plugins)) {
|
|
89
|
+
if (pluginConf === false || (typeof pluginConf === 'object' && pluginConf.enabled === false)) continue;
|
|
90
|
+
list.push(name);
|
|
91
|
+
if (typeof pluginConf === 'object') configs.set(name, pluginConf);
|
|
92
|
+
}
|
|
93
|
+
return { list, configs };
|
|
94
|
+
}
|
|
95
|
+
} catch {}
|
|
96
|
+
return { list: null, configs };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create the plugin event bus.
|
|
101
|
+
*/
|
|
102
|
+
export function createPluginEvents() {
|
|
103
|
+
const events = new EventEmitter();
|
|
104
|
+
events.setMaxListeners(50); // generous for many plugins
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Emit an event and await all listeners (including async ones).
|
|
108
|
+
* Use for mutating hooks where plugins must finish before core continues.
|
|
109
|
+
* Regular emit() is still used for fire-and-forget observational events.
|
|
110
|
+
*/
|
|
111
|
+
events.emitAsync = async function emitAsync(eventName, payload) {
|
|
112
|
+
const listeners = this.listeners(eventName);
|
|
113
|
+
await Promise.all(listeners.map(fn => fn(payload)));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return events;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load and register all plugins from plugins/<name>/index.js.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} app - Express app
|
|
123
|
+
* @param {object} ctx - Plugin context: { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession }
|
|
124
|
+
* Mutable -- plugins can replace ctx.createVirtualDisplay etc.
|
|
125
|
+
* @returns {string[]} - Names of loaded plugins
|
|
126
|
+
*/
|
|
127
|
+
export async function loadPlugins(app, ctx) {
|
|
128
|
+
const loaded = [];
|
|
129
|
+
|
|
130
|
+
if (!fs.existsSync(PLUGINS_DIR)) {
|
|
131
|
+
ctx.log('info', 'no plugins directory found, skipping plugin load');
|
|
132
|
+
return loaded;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { list: allowList, configs: pluginConfigs } = readPluginConfig();
|
|
136
|
+
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
|
137
|
+
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (!entry.isDirectory()) continue;
|
|
140
|
+
const name = entry.name;
|
|
141
|
+
|
|
142
|
+
// Skip directories starting with _ or .
|
|
143
|
+
if (name.startsWith('_') || name.startsWith('.')) continue;
|
|
144
|
+
|
|
145
|
+
// If camofox.config.json specifies a plugins list, only load those
|
|
146
|
+
if (allowList && !allowList.includes(name)) {
|
|
147
|
+
ctx.log('debug', `plugin "${name}" not in camofox.config.json plugins list, skipping`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const indexPath = path.join(PLUGINS_DIR, name, 'index.js');
|
|
152
|
+
if (!fs.existsSync(indexPath)) {
|
|
153
|
+
ctx.log('warn', `plugin "${name}" has no index.js, skipping`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const mod = await import(indexPath);
|
|
159
|
+
const register = mod.default || mod.register;
|
|
160
|
+
if (typeof register !== 'function') {
|
|
161
|
+
ctx.log('warn', `plugin "${name}" does not export a register function, skipping`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const pluginConfig = pluginConfigs.get(name) || {};
|
|
166
|
+
await register(app, ctx, pluginConfig);
|
|
167
|
+
loaded.push(name);
|
|
168
|
+
ctx.log('info', 'plugin loaded', { plugin: name });
|
|
169
|
+
} catch (err) {
|
|
170
|
+
ctx.log('error', 'plugin load failed', { plugin: name, error: err.message, stack: err.stack });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return loaded;
|
|
175
|
+
}
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Credential helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
function decodeProxyCredential(value) {
|
|
8
|
+
if (!value) return value;
|
|
9
|
+
try {
|
|
10
|
+
return decodeURIComponent(value);
|
|
11
|
+
} catch {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizePlaywrightProxy(proxy) {
|
|
17
|
+
if (!proxy) return proxy;
|
|
18
|
+
return {
|
|
19
|
+
...proxy,
|
|
20
|
+
username: decodeProxyCredential(proxy.username),
|
|
21
|
+
password: decodeProxyCredential(proxy.password),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Session helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function makeSessionId(prefix = 'sess') {
|
|
30
|
+
return `${prefix}-${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Provider interface
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
//
|
|
37
|
+
// A proxy provider shapes credentials and declares capabilities.
|
|
38
|
+
//
|
|
39
|
+
// {
|
|
40
|
+
// name: string -- e.g. 'decodo', 'brightdata', 'generic'
|
|
41
|
+
// canRotateSessions: bool -- per-context session rotation supported
|
|
42
|
+
// launchRetries: number -- how many browser launch attempts
|
|
43
|
+
// launchTimeoutMs: number -- per-attempt timeout
|
|
44
|
+
// buildSessionUsername(baseUsername, options) -> string
|
|
45
|
+
// buildProxyUrl(proxy, config) -> string | null
|
|
46
|
+
// }
|
|
47
|
+
//
|
|
48
|
+
// options: { country, state, city, zip, sessionId, sessionDurationMinutes }
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function sanitizeBackconnectValue(value) {
|
|
52
|
+
if (!value) return '';
|
|
53
|
+
return String(value)
|
|
54
|
+
.trim()
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/\s+/g, '_')
|
|
57
|
+
.replace(/[^a-z0-9_]/g, '_')
|
|
58
|
+
.replace(/_+/g, '_')
|
|
59
|
+
.replace(/^_+|_+$/g, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decodo residential proxy provider.
|
|
64
|
+
* Username DSL: user-{base}-country-{cc}-state-{st}-session-{id}-sessionduration-{min}
|
|
65
|
+
*/
|
|
66
|
+
export const decodoProvider = {
|
|
67
|
+
name: 'decodo',
|
|
68
|
+
canRotateSessions: true,
|
|
69
|
+
launchRetries: 10,
|
|
70
|
+
launchTimeoutMs: 180000,
|
|
71
|
+
|
|
72
|
+
buildSessionUsername(baseUsername, options = {}) {
|
|
73
|
+
const username = sanitizeBackconnectValue(baseUsername);
|
|
74
|
+
if (!username) return '';
|
|
75
|
+
|
|
76
|
+
const parts = [`user-${username}`];
|
|
77
|
+
const country = sanitizeBackconnectValue(options.country);
|
|
78
|
+
const state = sanitizeBackconnectValue(options.state);
|
|
79
|
+
const city = sanitizeBackconnectValue(options.city);
|
|
80
|
+
const zip = sanitizeBackconnectValue(options.zip);
|
|
81
|
+
const sessionId = sanitizeBackconnectValue(options.sessionId);
|
|
82
|
+
const sessionDurationMinutes = Number.isFinite(options.sessionDurationMinutes)
|
|
83
|
+
? Math.max(1, Math.min(1440, Math.trunc(options.sessionDurationMinutes)))
|
|
84
|
+
: null;
|
|
85
|
+
|
|
86
|
+
if (country) parts.push(`country-${country}`);
|
|
87
|
+
if (state) parts.push(`state-${state}`);
|
|
88
|
+
if (city) parts.push(`city-${city}`);
|
|
89
|
+
if (zip) parts.push(`zip-${zip}`);
|
|
90
|
+
if (sessionId) parts.push(`session-${sessionId}`);
|
|
91
|
+
if (sessionDurationMinutes) parts.push(`sessionduration-${sessionDurationMinutes}`);
|
|
92
|
+
|
|
93
|
+
return parts.join('-');
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
buildProxyUrl(proxy, config) {
|
|
97
|
+
if (!proxy?.username || !config?.password) return null;
|
|
98
|
+
const user = encodeURIComponent(proxy.username);
|
|
99
|
+
const pass = encodeURIComponent(config.password);
|
|
100
|
+
const host = config.backconnectHost;
|
|
101
|
+
const port = config.backconnectPort;
|
|
102
|
+
return `http://${user}:${pass}@${host}:${port}`;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Generic backconnect provider -- no username rewriting, just pass-through.
|
|
108
|
+
* Works with any SOCKS/HTTP proxy that supports sticky sessions via
|
|
109
|
+
* separate session IDs in the username field (e.g. BrightData, Oxylabs).
|
|
110
|
+
*/
|
|
111
|
+
export const genericBackconnectProvider = {
|
|
112
|
+
name: 'generic',
|
|
113
|
+
canRotateSessions: true,
|
|
114
|
+
launchRetries: 5,
|
|
115
|
+
launchTimeoutMs: 120000,
|
|
116
|
+
|
|
117
|
+
buildSessionUsername(baseUsername, options = {}) {
|
|
118
|
+
// Simple pass-through: base username + session suffix
|
|
119
|
+
const base = String(baseUsername || '').trim();
|
|
120
|
+
const sessionId = options.sessionId ? `-${String(options.sessionId).trim()}` : '';
|
|
121
|
+
return `${base}${sessionId}`;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
buildProxyUrl(proxy, config) {
|
|
125
|
+
if (!proxy?.username || !config?.password) return null;
|
|
126
|
+
const user = encodeURIComponent(proxy.username);
|
|
127
|
+
const pass = encodeURIComponent(config.password);
|
|
128
|
+
const host = config.backconnectHost;
|
|
129
|
+
const port = config.backconnectPort;
|
|
130
|
+
return `http://${user}:${pass}@${host}:${port}`;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Provider registry
|
|
135
|
+
const providers = {
|
|
136
|
+
decodo: decodoProvider,
|
|
137
|
+
generic: genericBackconnectProvider,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export function getProvider(name) {
|
|
141
|
+
return providers[name] || null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function registerProvider(name, provider) {
|
|
145
|
+
providers[name] = provider;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Proxy pool factory
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function buildBackconnectProxy(config, provider, sessionId) {
|
|
153
|
+
const username = provider.buildSessionUsername(config.username, {
|
|
154
|
+
country: config.country,
|
|
155
|
+
state: config.state,
|
|
156
|
+
city: config.city,
|
|
157
|
+
zip: config.zip,
|
|
158
|
+
sessionId,
|
|
159
|
+
sessionDurationMinutes: config.sessionDurationMinutes,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
server: `http://${config.backconnectHost}:${config.backconnectPort}`,
|
|
164
|
+
username,
|
|
165
|
+
password: config.password,
|
|
166
|
+
sessionId,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Create proxy strategy helpers.
|
|
172
|
+
* - round_robin: per-context port rotation across a fixed pool
|
|
173
|
+
* - backconnect: residential backconnect endpoint with sticky sessions (provider-shaped)
|
|
174
|
+
*/
|
|
175
|
+
export function createProxyPool(config) {
|
|
176
|
+
const {
|
|
177
|
+
strategy = 'round_robin',
|
|
178
|
+
host,
|
|
179
|
+
ports,
|
|
180
|
+
username,
|
|
181
|
+
password,
|
|
182
|
+
backconnectHost,
|
|
183
|
+
backconnectPort,
|
|
184
|
+
providerName,
|
|
185
|
+
} = config;
|
|
186
|
+
|
|
187
|
+
if (strategy === 'backconnect') {
|
|
188
|
+
if (!backconnectHost || !backconnectPort || !username || !password) return null;
|
|
189
|
+
|
|
190
|
+
const provider = getProvider(providerName || 'decodo') || decodoProvider;
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
mode: 'backconnect',
|
|
194
|
+
provider,
|
|
195
|
+
canRotateSessions: provider.canRotateSessions,
|
|
196
|
+
launchRetries: provider.launchRetries,
|
|
197
|
+
launchTimeoutMs: provider.launchTimeoutMs,
|
|
198
|
+
size: 1,
|
|
199
|
+
|
|
200
|
+
getLaunchProxy(sessionId = makeSessionId('browser')) {
|
|
201
|
+
return buildBackconnectProxy(config, provider, sessionId);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
getNext(sessionId = makeSessionId('ctx')) {
|
|
205
|
+
return buildBackconnectProxy(config, provider, sessionId);
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// round_robin -- no session rotation, single attempt
|
|
211
|
+
if (!host || !ports || ports.length === 0) return null;
|
|
212
|
+
|
|
213
|
+
let index = 0;
|
|
214
|
+
|
|
215
|
+
function makeProxy(port) {
|
|
216
|
+
return {
|
|
217
|
+
server: `http://${host}:${port}`,
|
|
218
|
+
username,
|
|
219
|
+
password,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
mode: 'round_robin',
|
|
225
|
+
provider: null,
|
|
226
|
+
canRotateSessions: false,
|
|
227
|
+
launchRetries: 1,
|
|
228
|
+
launchTimeoutMs: 60000,
|
|
229
|
+
size: ports.length,
|
|
230
|
+
|
|
231
|
+
getLaunchProxy() {
|
|
232
|
+
return makeProxy(ports[0]);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
getNext() {
|
|
236
|
+
const port = ports[index % ports.length];
|
|
237
|
+
index++;
|
|
238
|
+
return makeProxy(port);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// URL builder (for CLI tools like yt-dlp)
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Build a proxy URL string (http://user:pass@host:port) suitable for
|
|
249
|
+
* CLI tools like yt-dlp --proxy.
|
|
250
|
+
*/
|
|
251
|
+
export function buildProxyUrl(pool, config) {
|
|
252
|
+
if (!pool) return null;
|
|
253
|
+
|
|
254
|
+
if (pool.mode === 'backconnect') {
|
|
255
|
+
const proxy = pool.getLaunchProxy(makeSessionId('ytdlp'));
|
|
256
|
+
if (pool.provider?.buildProxyUrl) {
|
|
257
|
+
return pool.provider.buildProxyUrl(proxy, config);
|
|
258
|
+
}
|
|
259
|
+
// Fallback for pools without provider
|
|
260
|
+
if (!proxy?.username || !config?.password) return null;
|
|
261
|
+
const user = encodeURIComponent(proxy.username);
|
|
262
|
+
const pass = encodeURIComponent(config.password);
|
|
263
|
+
return `http://${user}:${pass}@${config.backconnectHost}:${config.backconnectPort}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// round_robin -- pick the first port
|
|
267
|
+
if (!config?.host || !config?.ports?.length) return null;
|
|
268
|
+
const user = config.username ? encodeURIComponent(config.username) : '';
|
|
269
|
+
const pass = config.password ? encodeURIComponent(config.password) : '';
|
|
270
|
+
const auth = user ? `${user}:${pass}@` : '';
|
|
271
|
+
return `http://${auth}${config.host}:${config.ports[0]}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Legacy alias for backward compatibility
|
|
275
|
+
export function buildDecodoBackconnectUsername(baseUsername, options) {
|
|
276
|
+
return decodoProvider.buildSessionUsername(baseUsername, options);
|
|
277
|
+
}
|