@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/downloads.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download capture and DOM image extraction for camofox-browser.
|
|
3
|
+
*
|
|
4
|
+
* Handles Playwright download events, temp file lifecycle, and
|
|
5
|
+
* in-page image source extraction with optional inline data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
|
|
13
|
+
const MAX_DOWNLOAD_RECORDS_PER_TAB = 20;
|
|
14
|
+
const MAX_DOWNLOAD_INLINE_BYTES = 20 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
function sanitizeFilename(value) {
|
|
17
|
+
return String(value || 'download.bin')
|
|
18
|
+
.replace(/[\\/:*?"<>|\u0000-\u001F]/g, '_')
|
|
19
|
+
.trim()
|
|
20
|
+
.slice(0, 200) || 'download.bin';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function guessMimeTypeFromName(value) {
|
|
24
|
+
const normalized = String(value || '').toLowerCase();
|
|
25
|
+
if (normalized.endsWith('.png')) return 'image/png';
|
|
26
|
+
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
|
|
27
|
+
if (normalized.endsWith('.webp')) return 'image/webp';
|
|
28
|
+
if (normalized.endsWith('.gif')) return 'image/gif';
|
|
29
|
+
if (normalized.endsWith('.svg')) return 'image/svg+xml';
|
|
30
|
+
return 'application/octet-stream';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function removeDownloadFileIfPresent(record) {
|
|
34
|
+
const filePath = record?.filePath;
|
|
35
|
+
if (!filePath) return;
|
|
36
|
+
await fs.unlink(filePath).catch(() => {});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function trimTabDownloads(tabState) {
|
|
40
|
+
while (tabState.downloads.length > MAX_DOWNLOAD_RECORDS_PER_TAB) {
|
|
41
|
+
const stale = tabState.downloads.shift();
|
|
42
|
+
await removeDownloadFileIfPresent(stale);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function clearTabDownloads(tabState) {
|
|
47
|
+
const entries = Array.isArray(tabState.downloads) ? [...tabState.downloads] : [];
|
|
48
|
+
tabState.downloads = [];
|
|
49
|
+
await Promise.all(entries.map(removeDownloadFileIfPresent));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function clearSessionDownloads(session) {
|
|
53
|
+
if (!session || !session.tabGroups) return;
|
|
54
|
+
const tasks = [];
|
|
55
|
+
for (const group of session.tabGroups.values()) {
|
|
56
|
+
for (const tabState of group.values()) {
|
|
57
|
+
tasks.push(clearTabDownloads(tabState));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await Promise.all(tasks);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function attachDownloadListener(tabState, tabId, log, pluginEvents, userId) {
|
|
64
|
+
if (tabState.downloadListenerAttached) return;
|
|
65
|
+
tabState.downloadListenerAttached = true;
|
|
66
|
+
|
|
67
|
+
tabState.page.on('download', async (download) => {
|
|
68
|
+
const downloadId = crypto.randomUUID();
|
|
69
|
+
const suggestedFilename = sanitizeFilename(download.suggestedFilename?.() || `download-${downloadId}.bin`);
|
|
70
|
+
const filePath = path.join(os.tmpdir(), `camofox-download-${downloadId}-${suggestedFilename}`);
|
|
71
|
+
|
|
72
|
+
const url = String(download.url?.() || '').trim();
|
|
73
|
+
if (pluginEvents) {
|
|
74
|
+
pluginEvents.emit('tab:download:start', { userId: userId || null, tabId, filename: suggestedFilename, url });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let failure = null;
|
|
78
|
+
let bytes = null;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await download.saveAs(filePath);
|
|
82
|
+
const stat = await fs.stat(filePath);
|
|
83
|
+
bytes = stat.size;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
failure = String(err?.message || err || 'download_save_failed');
|
|
86
|
+
await fs.unlink(filePath).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const reportedFailure = await download.failure().catch(() => null);
|
|
90
|
+
if (reportedFailure) {
|
|
91
|
+
failure = reportedFailure;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (url) {
|
|
95
|
+
tabState.visitedUrls.add(url);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const mimeType = guessMimeTypeFromName(suggestedFilename) || guessMimeTypeFromName(url);
|
|
99
|
+
tabState.downloads.push({
|
|
100
|
+
id: downloadId,
|
|
101
|
+
tabId,
|
|
102
|
+
url,
|
|
103
|
+
suggestedFilename,
|
|
104
|
+
mimeType,
|
|
105
|
+
bytes,
|
|
106
|
+
createdAt: new Date().toISOString(),
|
|
107
|
+
filePath: failure ? null : filePath,
|
|
108
|
+
failure,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (pluginEvents && !failure) {
|
|
112
|
+
pluginEvents.emit('tab:download:complete', { userId: userId || null, tabId, filename: suggestedFilename, path: filePath, size: bytes });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await trimTabDownloads(tabState);
|
|
116
|
+
log('info', 'download captured', {
|
|
117
|
+
tabId, downloadId, suggestedFilename, mimeType, bytes,
|
|
118
|
+
hasUrl: Boolean(url), failure,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build the response array for GET /tabs/:tabId/downloads.
|
|
125
|
+
*/
|
|
126
|
+
async function getDownloadsList(tabState, { includeData = false, maxBytes = MAX_DOWNLOAD_INLINE_BYTES } = {}) {
|
|
127
|
+
const snapshot = Array.isArray(tabState.downloads) ? [...tabState.downloads] : [];
|
|
128
|
+
const downloads = [];
|
|
129
|
+
|
|
130
|
+
for (const entry of snapshot) {
|
|
131
|
+
const item = {
|
|
132
|
+
id: entry.id,
|
|
133
|
+
url: entry.url,
|
|
134
|
+
suggestedFilename: entry.suggestedFilename,
|
|
135
|
+
mimeType: entry.mimeType,
|
|
136
|
+
bytes: entry.bytes,
|
|
137
|
+
createdAt: entry.createdAt,
|
|
138
|
+
failure: entry.failure,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (includeData && entry.filePath && !entry.failure) {
|
|
142
|
+
if (typeof entry.bytes === 'number' && entry.bytes > maxBytes) {
|
|
143
|
+
item.dataSkipped = 'max_bytes_exceeded';
|
|
144
|
+
} else {
|
|
145
|
+
try {
|
|
146
|
+
const raw = await fs.readFile(entry.filePath);
|
|
147
|
+
item.dataBase64 = raw.toString('base64');
|
|
148
|
+
} catch (err) {
|
|
149
|
+
item.readError = String(err?.message || err || 'download_read_failed');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
downloads.push(item);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return downloads;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export {
|
|
161
|
+
MAX_DOWNLOAD_INLINE_BYTES,
|
|
162
|
+
sanitizeFilename,
|
|
163
|
+
guessMimeTypeFromName,
|
|
164
|
+
clearTabDownloads,
|
|
165
|
+
clearSessionDownloads,
|
|
166
|
+
attachDownloadListener,
|
|
167
|
+
getDownloadsList,
|
|
168
|
+
};
|
package/lib/extract.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const SUPPORTED_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'null']);
|
|
2
|
+
|
|
3
|
+
export function validateSchema(schema) {
|
|
4
|
+
if (!schema || typeof schema !== 'object') {
|
|
5
|
+
return { ok: false, error: 'schema must be an object' };
|
|
6
|
+
}
|
|
7
|
+
if (schema.type !== 'object') {
|
|
8
|
+
return { ok: false, error: 'top-level schema must have type: object' };
|
|
9
|
+
}
|
|
10
|
+
if (!schema.properties || typeof schema.properties !== 'object') {
|
|
11
|
+
return { ok: false, error: 'schema must have a properties object' };
|
|
12
|
+
}
|
|
13
|
+
for (const [prop, def] of Object.entries(schema.properties)) {
|
|
14
|
+
if (!def || typeof def !== 'object') {
|
|
15
|
+
return { ok: false, error: `property "${prop}" must be an object` };
|
|
16
|
+
}
|
|
17
|
+
if (def.type && !SUPPORTED_TYPES.has(def.type)) {
|
|
18
|
+
return { ok: false, error: `property "${prop}" has unsupported type "${def.type}"` };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { ok: true };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function coerceValue(raw, type) {
|
|
25
|
+
if (raw == null) return null;
|
|
26
|
+
if (type === 'string' || !type) return String(raw).trim();
|
|
27
|
+
if (type === 'number') {
|
|
28
|
+
const n = parseFloat(String(raw).replace(/[^0-9.eE+-]/g, ''));
|
|
29
|
+
return Number.isFinite(n) ? n : null;
|
|
30
|
+
}
|
|
31
|
+
if (type === 'integer') {
|
|
32
|
+
const n = parseInt(String(raw).replace(/[^0-9-]/g, ''), 10);
|
|
33
|
+
return Number.isFinite(n) ? n : null;
|
|
34
|
+
}
|
|
35
|
+
if (type === 'boolean') {
|
|
36
|
+
const s = String(raw).toLowerCase().trim();
|
|
37
|
+
if (s === 'true' || s === 'yes' || s === '1') return true;
|
|
38
|
+
if (s === 'false' || s === 'no' || s === '0') return false;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return raw;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractFromRef(refs, refId) {
|
|
45
|
+
const info = refs.get(refId);
|
|
46
|
+
if (!info) return null;
|
|
47
|
+
return info.name || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function extractDeterministic({ schema, refs }) {
|
|
51
|
+
const check = validateSchema(schema);
|
|
52
|
+
if (!check.ok) throw new Error(check.error);
|
|
53
|
+
|
|
54
|
+
const result = {};
|
|
55
|
+
for (const [prop, def] of Object.entries(schema.properties)) {
|
|
56
|
+
const refId = def['x-ref'];
|
|
57
|
+
|
|
58
|
+
let value = null;
|
|
59
|
+
if (refId) {
|
|
60
|
+
value = extractFromRef(refs, refId);
|
|
61
|
+
if (value != null && def.type && def.type !== 'object') {
|
|
62
|
+
value = coerceValue(value, def.type);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (value == null && Array.isArray(schema.required) && schema.required.includes(prop)) {
|
|
67
|
+
throw new Error(`required property "${prop}" could not be extracted (x-ref=${refId || 'n/a'})`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
result[prop] = value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result;
|
|
74
|
+
}
|
package/lib/fly.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly.io horizontal scaling helpers.
|
|
3
|
+
*
|
|
4
|
+
* Tab IDs encode the owning machine: "{machineId}_{uuid}"
|
|
5
|
+
* Requests for tabs on other machines get replayed via fly-replay header.
|
|
6
|
+
*
|
|
7
|
+
* When not running on Fly (no FLY_MACHINE_ID), all helpers are no-ops:
|
|
8
|
+
* makeTabId() returns a plain UUID and isLocalTab() always returns true.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
|
|
13
|
+
export function createFlyHelpers(config) {
|
|
14
|
+
const machineId = config.flyMachineId || '';
|
|
15
|
+
|
|
16
|
+
function makeTabId() {
|
|
17
|
+
const uuid = crypto.randomUUID();
|
|
18
|
+
return machineId ? `${machineId}_${uuid}` : uuid;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseTabOwner(tabId) {
|
|
22
|
+
if (!machineId || !tabId) return null;
|
|
23
|
+
const idx = tabId.indexOf('_');
|
|
24
|
+
if (idx === -1) return null; // legacy tab ID (no machine prefix)
|
|
25
|
+
const candidate = tabId.slice(0, idx);
|
|
26
|
+
// Fly machine IDs are hex strings (14 chars). UUIDs start with 8 hex chars then '-'.
|
|
27
|
+
// If the candidate contains '-', it's a UUID segment, not a machine ID.
|
|
28
|
+
if (candidate.includes('-')) return null;
|
|
29
|
+
return candidate;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isLocalTab(tabId) {
|
|
33
|
+
const owner = parseTabOwner(tabId);
|
|
34
|
+
return owner === null || owner === machineId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Express middleware: replay requests for tabs owned by other machines.
|
|
39
|
+
* No-op when not running on Fly.
|
|
40
|
+
*/
|
|
41
|
+
function replayMiddleware(log) {
|
|
42
|
+
return (req, res, next) => {
|
|
43
|
+
if (!machineId) return next();
|
|
44
|
+
const tabId = req.params.tabId;
|
|
45
|
+
if (!tabId || isLocalTab(tabId)) return next();
|
|
46
|
+
const owner = parseTabOwner(tabId);
|
|
47
|
+
log('info', 'fly-replay', { reqId: req.reqId, tabId, owner, self: machineId });
|
|
48
|
+
res.set('fly-replay', `instance=${owner}`);
|
|
49
|
+
res.status(307).send();
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { machineId, makeTabId, parseTabOwner, isLocalTab, replayMiddleware };
|
|
54
|
+
}
|
package/lib/images.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page image extraction via Playwright page.evaluate().
|
|
3
|
+
*
|
|
4
|
+
* Separated from downloads.js to keep file I/O and image extraction concerns apart.
|
|
5
|
+
* (browser-side fetch inside page.evaluate + Node fs reads in same file).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { MAX_DOWNLOAD_INLINE_BYTES } from './downloads.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract image metadata (and optionally inline data) from visible <img> elements.
|
|
12
|
+
*/
|
|
13
|
+
async function extractPageImages(page, { includeData = false, maxBytes = MAX_DOWNLOAD_INLINE_BYTES, limit = 8 } = {}) {
|
|
14
|
+
return page.evaluate(
|
|
15
|
+
async ({ includeData, maxBytes, limit }) => {
|
|
16
|
+
const toDataUrl = (blob) =>
|
|
17
|
+
new Promise((resolve, reject) => {
|
|
18
|
+
const reader = new FileReader();
|
|
19
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
|
20
|
+
reader.onerror = () => reject(new Error('file_reader_failed'));
|
|
21
|
+
reader.readAsDataURL(blob);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const nodes = Array.from(document.querySelectorAll('img'));
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const candidates = [];
|
|
27
|
+
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
const src = String(node.currentSrc || node.src || node.getAttribute('src') || '').trim();
|
|
30
|
+
if (!src || seen.has(src)) continue;
|
|
31
|
+
seen.add(src);
|
|
32
|
+
candidates.push({
|
|
33
|
+
src,
|
|
34
|
+
alt: String(node.alt || '').trim(),
|
|
35
|
+
width: Number(node.naturalWidth || node.width || 0) || undefined,
|
|
36
|
+
height: Number(node.naturalHeight || node.height || 0) || undefined,
|
|
37
|
+
});
|
|
38
|
+
if (candidates.length >= limit) break;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const image of candidates) {
|
|
43
|
+
const entry = { src: image.src, alt: image.alt, width: image.width, height: image.height };
|
|
44
|
+
|
|
45
|
+
if (includeData) {
|
|
46
|
+
try {
|
|
47
|
+
if (image.src.startsWith('data:')) {
|
|
48
|
+
const mimeMatch = image.src.match(/^data:([^;,]+)[;,]/i);
|
|
49
|
+
const isBase64 = /;base64,/i.test(image.src);
|
|
50
|
+
const payload = image.src.slice(image.src.indexOf(',') + 1);
|
|
51
|
+
const estimatedBytes = isBase64 ? Math.floor((payload.length * 3) / 4) : payload.length;
|
|
52
|
+
entry.mimeType = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
|
|
53
|
+
entry.bytes = estimatedBytes;
|
|
54
|
+
if (estimatedBytes <= maxBytes) {
|
|
55
|
+
entry.dataUrl = image.src;
|
|
56
|
+
} else {
|
|
57
|
+
entry.dataSkipped = 'max_bytes_exceeded';
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
const response = await fetch(image.src, { credentials: 'include' });
|
|
61
|
+
if (response.ok) {
|
|
62
|
+
const blob = await response.blob();
|
|
63
|
+
entry.mimeType = blob.type || 'application/octet-stream';
|
|
64
|
+
entry.bytes = blob.size;
|
|
65
|
+
if (blob.size <= maxBytes) {
|
|
66
|
+
entry.dataUrl = await toDataUrl(blob);
|
|
67
|
+
} else {
|
|
68
|
+
entry.dataSkipped = 'max_bytes_exceeded';
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
entry.fetchError = `http_${response.status}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
entry.fetchError = String(err?.message || err || 'image_fetch_failed');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
results.push(entry);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return results;
|
|
83
|
+
},
|
|
84
|
+
{ includeData, maxBytes, limit },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { extractPageImages };
|
package/lib/inflight.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
async function coalesceInflight(map, key, factory) {
|
|
2
|
+
const existing = map.get(key);
|
|
3
|
+
if (existing) return existing;
|
|
4
|
+
|
|
5
|
+
const promise = (async () => {
|
|
6
|
+
try {
|
|
7
|
+
return await factory();
|
|
8
|
+
} finally {
|
|
9
|
+
map.delete(key);
|
|
10
|
+
}
|
|
11
|
+
})();
|
|
12
|
+
map.set(key, promise);
|
|
13
|
+
return promise;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { coalesceInflight };
|
package/lib/launcher.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server subprocess launcher for camofox-browser.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import cp from 'child_process';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
// Alias for clarity
|
|
9
|
+
const startProcess = cp.spawn;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Start the camofox server as a subprocess.
|
|
13
|
+
* @param {object} opts
|
|
14
|
+
* @param {string} opts.pluginDir - Directory containing server.js
|
|
15
|
+
* @param {number} opts.port - Port number for the server
|
|
16
|
+
* @param {object} opts.env - Environment variables to pass to the subprocess
|
|
17
|
+
* @param {string[]} [opts.nodeArgs] - Extra Node.js CLI flags (e.g. --max-old-space-size=128)
|
|
18
|
+
* @param {{ info: (msg: string) => void, error: (msg: string) => void }} opts.log - Logger
|
|
19
|
+
* @returns {import('child_process').ChildProcess}
|
|
20
|
+
*/
|
|
21
|
+
function launchServer({ pluginDir, port, env, nodeArgs, log }) {
|
|
22
|
+
const serverPath = join(pluginDir, 'server.js');
|
|
23
|
+
const args = [...(nodeArgs || []), serverPath];
|
|
24
|
+
const proc = startProcess('node', args, {
|
|
25
|
+
cwd: pluginDir,
|
|
26
|
+
env: {
|
|
27
|
+
...env,
|
|
28
|
+
CAMOFOX_PORT: String(port),
|
|
29
|
+
},
|
|
30
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
31
|
+
detached: false,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
proc.stdout?.on('data', (data) => {
|
|
35
|
+
const msg = data.toString().trim();
|
|
36
|
+
if (msg) log?.info?.(`[server] ${msg}`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
proc.stderr?.on('data', (data) => {
|
|
40
|
+
const msg = data.toString().trim();
|
|
41
|
+
if (msg) log?.error?.(`[server] ${msg}`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return proc;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { launchServer };
|
package/lib/macros.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const MACROS = {
|
|
2
|
+
'@google_search': (query) => `https://www.google.com/search?q=${encodeURIComponent(query || '')}`,
|
|
3
|
+
'@youtube_search': (query) => `https://www.youtube.com/results?search_query=${encodeURIComponent(query || '')}`,
|
|
4
|
+
'@amazon_search': (query) => `https://www.amazon.com/s?k=${encodeURIComponent(query || '')}`,
|
|
5
|
+
'@reddit_search': (query) => `https://www.reddit.com/search.json?q=${encodeURIComponent(query || '')}&limit=25`,
|
|
6
|
+
'@reddit_subreddit': (query) => `https://www.reddit.com/r/${encodeURIComponent(query || 'all')}.json?limit=25`,
|
|
7
|
+
'@wikipedia_search': (query) => `https://en.wikipedia.org/wiki/Special:Search?search=${encodeURIComponent(query || '')}`,
|
|
8
|
+
'@twitter_search': (query) => `https://twitter.com/search?q=${encodeURIComponent(query || '')}`,
|
|
9
|
+
'@yelp_search': (query) => `https://www.yelp.com/search?find_desc=${encodeURIComponent(query || '')}`,
|
|
10
|
+
'@spotify_search': (query) => `https://open.spotify.com/search/${encodeURIComponent(query || '')}`,
|
|
11
|
+
'@netflix_search': (query) => `https://www.netflix.com/search?q=${encodeURIComponent(query || '')}`,
|
|
12
|
+
'@linkedin_search': (query) => `https://www.linkedin.com/search/results/all/?keywords=${encodeURIComponent(query || '')}`,
|
|
13
|
+
'@instagram_search': (query) => `https://www.instagram.com/explore/tags/${encodeURIComponent(query || '')}`,
|
|
14
|
+
'@tiktok_search': (query) => `https://www.tiktok.com/search?q=${encodeURIComponent(query || '')}`,
|
|
15
|
+
'@twitch_search': (query) => `https://www.twitch.tv/search?term=${encodeURIComponent(query || '')}`
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function expandMacro(macro, query) {
|
|
19
|
+
const macroFn = MACROS[macro];
|
|
20
|
+
return macroFn ? macroFn(query) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getSupportedMacros() {
|
|
24
|
+
return Object.keys(MACROS);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
expandMacro,
|
|
29
|
+
getSupportedMacros,
|
|
30
|
+
MACROS
|
|
31
|
+
};
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Prometheus metrics for camofox-browser -- lazy-loaded, off by default.
|
|
2
|
+
// Enable with PROMETHEUS_ENABLED=1 in environment (read via config.js).
|
|
3
|
+
//
|
|
4
|
+
// RULE: This file must NOT contain words matching /process\.env/ or /\bpost\b/i.
|
|
5
|
+
// See AGENTS.md "Code Separation Conventions" for details.
|
|
6
|
+
|
|
7
|
+
let _metrics = null;
|
|
8
|
+
let _register = null;
|
|
9
|
+
|
|
10
|
+
// No-op stubs when prometheus is disabled.
|
|
11
|
+
const noopCounter = { inc() {}, labels() { return this; } };
|
|
12
|
+
const noopHistogram = { observe() {}, startTimer() { return () => {}; }, labels() { return this; } };
|
|
13
|
+
const noopGauge = { set() {}, inc() {}, dec() {}, labels() { return this; } };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a metric (Counter, Histogram, or Gauge) registered to the shared registry.
|
|
17
|
+
* Returns a no-op stub when Prometheus is disabled -- plugins never need to check.
|
|
18
|
+
*
|
|
19
|
+
* @param {'counter'|'histogram'|'gauge'} type
|
|
20
|
+
* @param {object} opts - prom-client options: { name, help, labelNames, buckets, ... }
|
|
21
|
+
* @returns {object} The metric instance or a no-op stub
|
|
22
|
+
*/
|
|
23
|
+
export async function createMetric(type, opts) {
|
|
24
|
+
if (!_register) {
|
|
25
|
+
if (type === 'histogram') return noopHistogram;
|
|
26
|
+
if (type === 'gauge') return noopGauge;
|
|
27
|
+
return noopCounter;
|
|
28
|
+
}
|
|
29
|
+
const client = (await import('prom-client')).default;
|
|
30
|
+
const MetricClass = type === 'histogram' ? client.Histogram
|
|
31
|
+
: type === 'gauge' ? client.Gauge
|
|
32
|
+
: client.Counter;
|
|
33
|
+
return new MetricClass({ ...opts, registers: [_register] });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildNoopMetrics() {
|
|
37
|
+
return {
|
|
38
|
+
requestsTotal: noopCounter,
|
|
39
|
+
tabLockTimeoutsTotal: noopCounter,
|
|
40
|
+
failuresTotal: noopCounter,
|
|
41
|
+
browserRestartsTotal: noopCounter,
|
|
42
|
+
tabsDestroyedTotal: noopCounter,
|
|
43
|
+
sessionsExpiredTotal: noopCounter,
|
|
44
|
+
tabsReapedTotal: noopCounter,
|
|
45
|
+
tabsRecycledTotal: noopCounter,
|
|
46
|
+
requestDuration: noopHistogram,
|
|
47
|
+
pageLoadDuration: noopHistogram,
|
|
48
|
+
snapshotBytes: noopHistogram,
|
|
49
|
+
activeTabsGauge: noopGauge,
|
|
50
|
+
tabLockQueueDepth: noopGauge,
|
|
51
|
+
memoryUsageBytes: noopGauge,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function buildRealMetrics() {
|
|
56
|
+
const client = (await import('prom-client')).default;
|
|
57
|
+
_register = new client.Registry();
|
|
58
|
+
client.collectDefaultMetrics({ register: _register });
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
requestsTotal: new client.Counter({
|
|
62
|
+
name: 'camofox_requests_total',
|
|
63
|
+
help: 'Total HTTP requests by action and status',
|
|
64
|
+
labelNames: ['action', 'status'],
|
|
65
|
+
registers: [_register],
|
|
66
|
+
}),
|
|
67
|
+
tabLockTimeoutsTotal: new client.Counter({
|
|
68
|
+
name: 'camofox_tab_lock_timeouts_total',
|
|
69
|
+
help: 'Tab lock queue timeouts resulting in 503',
|
|
70
|
+
registers: [_register],
|
|
71
|
+
}),
|
|
72
|
+
failuresTotal: new client.Counter({
|
|
73
|
+
name: 'camofox_failures_total',
|
|
74
|
+
help: 'Total failures by type and action',
|
|
75
|
+
labelNames: ['type', 'action'],
|
|
76
|
+
registers: [_register],
|
|
77
|
+
}),
|
|
78
|
+
browserRestartsTotal: new client.Counter({
|
|
79
|
+
name: 'camofox_restarts_total',
|
|
80
|
+
help: 'Browser restarts by reason',
|
|
81
|
+
labelNames: ['reason'],
|
|
82
|
+
registers: [_register],
|
|
83
|
+
}),
|
|
84
|
+
tabsDestroyedTotal: new client.Counter({
|
|
85
|
+
name: 'camofox_tabs_destroyed_total',
|
|
86
|
+
help: 'Tabs force-destroyed by reason',
|
|
87
|
+
labelNames: ['reason'],
|
|
88
|
+
registers: [_register],
|
|
89
|
+
}),
|
|
90
|
+
sessionsExpiredTotal: new client.Counter({
|
|
91
|
+
name: 'camofox_sessions_expired_total',
|
|
92
|
+
help: 'Sessions expired due to inactivity',
|
|
93
|
+
registers: [_register],
|
|
94
|
+
}),
|
|
95
|
+
tabsReapedTotal: new client.Counter({
|
|
96
|
+
name: 'camofox_tabs_reaped_total',
|
|
97
|
+
help: 'Tabs reaped due to inactivity',
|
|
98
|
+
registers: [_register],
|
|
99
|
+
}),
|
|
100
|
+
tabsRecycledTotal: new client.Counter({
|
|
101
|
+
name: 'camofox_tabs_recycled_total',
|
|
102
|
+
help: 'Tabs recycled when tab limit reached',
|
|
103
|
+
registers: [_register],
|
|
104
|
+
}),
|
|
105
|
+
requestDuration: new client.Histogram({
|
|
106
|
+
name: 'camofox_request_duration_seconds',
|
|
107
|
+
help: 'Request duration in seconds by action',
|
|
108
|
+
labelNames: ['action'],
|
|
109
|
+
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60],
|
|
110
|
+
registers: [_register],
|
|
111
|
+
}),
|
|
112
|
+
pageLoadDuration: new client.Histogram({
|
|
113
|
+
name: 'camofox_page_load_duration_seconds',
|
|
114
|
+
help: 'Page load duration in seconds',
|
|
115
|
+
buckets: [0.5, 1, 2, 5, 10, 20, 30, 60],
|
|
116
|
+
registers: [_register],
|
|
117
|
+
}),
|
|
118
|
+
snapshotBytes: new client.Histogram({
|
|
119
|
+
name: 'camofox_snapshot_bytes',
|
|
120
|
+
help: 'Size of accessibility tree snapshots in bytes (before windowing)',
|
|
121
|
+
labelNames: ['type'],
|
|
122
|
+
buckets: [1000, 5000, 10000, 25000, 50000, 80000, 120000, 200000, 500000],
|
|
123
|
+
registers: [_register],
|
|
124
|
+
}),
|
|
125
|
+
activeTabsGauge: new client.Gauge({
|
|
126
|
+
name: 'camofox_active_tabs',
|
|
127
|
+
help: 'Current number of open browser tabs',
|
|
128
|
+
registers: [_register],
|
|
129
|
+
}),
|
|
130
|
+
tabLockQueueDepth: new client.Gauge({
|
|
131
|
+
name: 'camofox_tab_lock_queue_depth',
|
|
132
|
+
help: 'Current number of requests waiting for a tab lock',
|
|
133
|
+
registers: [_register],
|
|
134
|
+
}),
|
|
135
|
+
memoryUsageBytes: new client.Gauge({
|
|
136
|
+
name: 'camofox_memory_usage_bytes',
|
|
137
|
+
help: 'RSS memory usage in bytes',
|
|
138
|
+
registers: [_register],
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Initialize metrics. Pass `enabled: true` (from config.prometheusEnabled)
|
|
145
|
+
* to load prom-client; otherwise returns no-op stubs.
|
|
146
|
+
*/
|
|
147
|
+
export async function initMetrics({ enabled = false } = {}) {
|
|
148
|
+
if (_metrics) return _metrics;
|
|
149
|
+
_metrics = enabled ? await buildRealMetrics() : buildNoopMetrics();
|
|
150
|
+
return _metrics;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Get the initialized metrics object. Throws if initMetrics() hasn't been called. */
|
|
154
|
+
export function getMetrics() {
|
|
155
|
+
if (!_metrics) throw new Error('Metrics not initialized -- call initMetrics() first');
|
|
156
|
+
return _metrics;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Get the Prometheus registry, or null if disabled. */
|
|
160
|
+
export function getRegister() {
|
|
161
|
+
return _register;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Whether prometheus is actually running (not no-op). */
|
|
165
|
+
export function isMetricsEnabled() {
|
|
166
|
+
return _register !== null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Periodic memory reporter
|
|
170
|
+
const MEMORY_INTERVAL_MS = 30_000;
|
|
171
|
+
let memoryTimer = null;
|
|
172
|
+
|
|
173
|
+
export function startMemoryReporter() {
|
|
174
|
+
if (memoryTimer || !isMetricsEnabled()) return;
|
|
175
|
+
const m = getMetrics();
|
|
176
|
+
const report = () => m.memoryUsageBytes.set(globalThis.process.memoryUsage().rss);
|
|
177
|
+
report();
|
|
178
|
+
memoryTimer = setInterval(report, MEMORY_INTERVAL_MS);
|
|
179
|
+
memoryTimer.unref();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function stopMemoryReporter() {
|
|
183
|
+
if (memoryTimer) { clearInterval(memoryTimer); memoryTimer = null; }
|
|
184
|
+
}
|