@askjo/camofox-browser 1.0.14 → 1.1.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 +94 -4
- package/lib/macros.js +2 -1
- package/package.json +1 -1
- package/plugin.ts +136 -10
- package/server.js +337 -105
package/README.md
CHANGED
|
@@ -33,7 +33,10 @@ This project wraps that engine in a REST API built for agents: accessibility sna
|
|
|
33
33
|
- **Element Refs** — stable `e1`, `e2`, `e3` identifiers for reliable interaction
|
|
34
34
|
- **Token-Efficient** — accessibility snapshots are ~90% smaller than raw HTML
|
|
35
35
|
- **Session Isolation** — separate cookies/storage per user
|
|
36
|
-
- **
|
|
36
|
+
- **Cookie Import** — inject Netscape-format cookie files for authenticated browsing
|
|
37
|
+
- **Proxy + GeoIP** — route traffic through residential proxies with automatic locale/timezone
|
|
38
|
+
- **Structured Logging** — JSON log lines with request IDs for production observability
|
|
39
|
+
- **Search Macros** — `@google_search`, `@youtube_search`, `@amazon_search`, `@reddit_subreddit`, and 10 more
|
|
37
40
|
- **Deploy Anywhere** — Docker, Fly.io, Railway
|
|
38
41
|
|
|
39
42
|
## Quick Start
|
|
@@ -44,7 +47,7 @@ This project wraps that engine in a REST API built for agents: accessibility sna
|
|
|
44
47
|
openclaw plugins install @askjo/camofox-browser
|
|
45
48
|
```
|
|
46
49
|
|
|
47
|
-
**Tools:** `camofox_create_tab` · `camofox_snapshot` · `camofox_click` · `camofox_type` · `camofox_navigate` · `camofox_scroll` · `camofox_screenshot` · `camofox_close_tab` · `camofox_list_tabs`
|
|
50
|
+
**Tools:** `camofox_create_tab` · `camofox_snapshot` · `camofox_click` · `camofox_type` · `camofox_navigate` · `camofox_scroll` · `camofox_screenshot` · `camofox_close_tab` · `camofox_list_tabs` · `camofox_import_cookies`
|
|
48
51
|
|
|
49
52
|
### Standalone
|
|
50
53
|
|
|
@@ -55,7 +58,7 @@ npm install
|
|
|
55
58
|
npm start # downloads Camoufox on first run (~300MB)
|
|
56
59
|
```
|
|
57
60
|
|
|
58
|
-
Default port is `9377`.
|
|
61
|
+
Default port is `9377`. See [Environment Variables](#environment-variables) for all options.
|
|
59
62
|
|
|
60
63
|
### Docker
|
|
61
64
|
|
|
@@ -70,6 +73,70 @@ docker run -p 9377:9377 camofox-browser
|
|
|
70
73
|
|
|
71
74
|
## Usage
|
|
72
75
|
|
|
76
|
+
### Cookie Injection (Netscape cookie file → Playwright cookies)
|
|
77
|
+
|
|
78
|
+
If you’re using the OpenClaw plugin, you can import a Netscape-format cookie file (e.g., exported from a browser) to authenticate sessions without interactive login.
|
|
79
|
+
|
|
80
|
+
- Tool: `camofox_import_cookies`
|
|
81
|
+
- Server endpoint: `POST /sessions/:userId/cookies`
|
|
82
|
+
|
|
83
|
+
**Security:** this endpoint is disabled unless `CAMOFOX_API_KEY` is set on the server. When enabled, callers must include `Authorization: Bearer <CAMOFOX_API_KEY>`.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# OpenClaw tool usage (conceptual)
|
|
87
|
+
# camofox_import_cookies({ cookiesPath: "linkedin.txt", domainSuffix: "linkedin.com" })
|
|
88
|
+
|
|
89
|
+
# Direct server usage (Playwright cookie objects)
|
|
90
|
+
curl -X POST http://localhost:9377/sessions/agent1/cookies \
|
|
91
|
+
-H 'Content-Type: application/json' \
|
|
92
|
+
-H 'Authorization: Bearer YOUR_CAMOFOX_API_KEY' \
|
|
93
|
+
-d '{"cookies":[{"name":"foo","value":"bar","domain":"example.com","path":"/","expires":-1,"httpOnly":false,"secure":false}]}'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Proxy + GeoIP
|
|
97
|
+
|
|
98
|
+
Route all browser traffic through a proxy with automatic locale, timezone, and geolocation derived from the proxy's IP address via Camoufox's built-in GeoIP.
|
|
99
|
+
|
|
100
|
+
Set these environment variables before starting the server:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
export PROXY_HOST=166.88.179.132
|
|
104
|
+
export PROXY_PORT=46040
|
|
105
|
+
export PROXY_USERNAME=myuser
|
|
106
|
+
export PROXY_PASSWORD=mypass
|
|
107
|
+
npm start
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or in Docker:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
docker run -p 9377:9377 \
|
|
114
|
+
-e PROXY_HOST=166.88.179.132 \
|
|
115
|
+
-e PROXY_PORT=46040 \
|
|
116
|
+
-e PROXY_USERNAME=myuser \
|
|
117
|
+
-e PROXY_PASSWORD=mypass \
|
|
118
|
+
camofox-browser
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
When a proxy is configured:
|
|
122
|
+
- All traffic routes through the proxy
|
|
123
|
+
- Camoufox's GeoIP automatically sets `locale`, `timezone`, and `geolocation` to match the proxy's exit IP
|
|
124
|
+
- Browser fingerprint (language, timezone, coordinates) is consistent with the proxy location
|
|
125
|
+
- Without a proxy, defaults to `en-US`, `America/Los_Angeles`, San Francisco coordinates
|
|
126
|
+
|
|
127
|
+
### Structured Logging
|
|
128
|
+
|
|
129
|
+
All log output is JSON (one object per line) for easy parsing by log aggregators:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{"ts":"2026-02-11T23:45:01.234Z","level":"info","msg":"req","reqId":"a1b2c3d4","method":"POST","path":"/tabs","userId":"agent1"}
|
|
133
|
+
{"ts":"2026-02-11T23:45:01.567Z","level":"info","msg":"res","reqId":"a1b2c3d4","status":200,"ms":333}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Health check requests (`/health`) are excluded from request logging to reduce noise.
|
|
137
|
+
|
|
138
|
+
### Basic Browsing
|
|
139
|
+
|
|
73
140
|
```bash
|
|
74
141
|
# Create a tab
|
|
75
142
|
curl -X POST http://localhost:9377/tabs \
|
|
@@ -134,9 +201,32 @@ curl -X POST http://localhost:9377/tabs/TAB_ID/navigate \
|
|
|
134
201
|
| `POST` | `/start` | Start browser engine |
|
|
135
202
|
| `POST` | `/stop` | Stop browser engine |
|
|
136
203
|
|
|
204
|
+
### Sessions
|
|
205
|
+
|
|
206
|
+
| Method | Endpoint | Description |
|
|
207
|
+
|--------|----------|-------------|
|
|
208
|
+
| `POST` | `/sessions/:userId/cookies` | Add cookies to a user session (Playwright cookie objects) |
|
|
209
|
+
|
|
137
210
|
## Search Macros
|
|
138
211
|
|
|
139
|
-
`@google_search` · `@youtube_search` · `@amazon_search` · `@reddit_search` · `@wikipedia_search` · `@twitter_search` · `@yelp_search` · `@spotify_search` · `@netflix_search` · `@linkedin_search` · `@instagram_search` · `@tiktok_search` · `@twitch_search`
|
|
212
|
+
`@google_search` · `@youtube_search` · `@amazon_search` · `@reddit_search` · `@reddit_subreddit` · `@wikipedia_search` · `@twitter_search` · `@yelp_search` · `@spotify_search` · `@netflix_search` · `@linkedin_search` · `@instagram_search` · `@tiktok_search` · `@twitch_search`
|
|
213
|
+
|
|
214
|
+
Reddit macros return JSON directly (no HTML parsing needed):
|
|
215
|
+
- `@reddit_search` — search all of Reddit, returns JSON with 25 results
|
|
216
|
+
- `@reddit_subreddit` — browse a subreddit (e.g., query `"programming"` → `/r/programming.json`)
|
|
217
|
+
|
|
218
|
+
## Environment Variables
|
|
219
|
+
|
|
220
|
+
| Variable | Description | Default |
|
|
221
|
+
|----------|-------------|---------|
|
|
222
|
+
| `CAMOFOX_PORT` | Server port | `9377` |
|
|
223
|
+
| `CAMOFOX_API_KEY` | Enable cookie import endpoint (disabled if unset) | — |
|
|
224
|
+
| `CAMOFOX_ADMIN_KEY` | Required for `POST /stop` | — |
|
|
225
|
+
| `CAMOFOX_COOKIES_DIR` | Directory for cookie files | `~/.camofox/cookies` |
|
|
226
|
+
| `PROXY_HOST` | Proxy hostname or IP | — |
|
|
227
|
+
| `PROXY_PORT` | Proxy port | — |
|
|
228
|
+
| `PROXY_USERNAME` | Proxy auth username | — |
|
|
229
|
+
| `PROXY_PASSWORD` | Proxy auth password | — |
|
|
140
230
|
|
|
141
231
|
## Architecture
|
|
142
232
|
|
package/lib/macros.js
CHANGED
|
@@ -2,7 +2,8 @@ const MACROS = {
|
|
|
2
2
|
'@google_search': (query) => `https://www.google.com/search?q=${encodeURIComponent(query || '')}`,
|
|
3
3
|
'@youtube_search': (query) => `https://www.youtube.com/results?search_query=${encodeURIComponent(query || '')}`,
|
|
4
4
|
'@amazon_search': (query) => `https://www.amazon.com/s?k=${encodeURIComponent(query || '')}`,
|
|
5
|
-
'@reddit_search': (query) => `https://www.reddit.com/search
|
|
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`,
|
|
6
7
|
'@wikipedia_search': (query) => `https://en.wikipedia.org/wiki/Special:Search?search=${encodeURIComponent(query || '')}`,
|
|
7
8
|
'@twitter_search': (query) => `https://twitter.com/search?q=${encodeURIComponent(query || '')}`,
|
|
8
9
|
'@yelp_search': (query) => `https://www.yelp.com/search?find_desc=${encodeURIComponent(query || '')}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askjo/camofox-browser",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Headless browser automation server and OpenClaw plugin for AI agents - anti-detection, element refs, and session isolation",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"license": "MIT",
|
package/plugin.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawn, ChildProcess } from "child_process";
|
|
9
|
-
import { join, dirname } from "path";
|
|
9
|
+
import { join, dirname, resolve, sep } from "path";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
import { homedir } from "os";
|
|
11
13
|
|
|
12
14
|
// Get plugin directory - works in both ESM and CJS contexts
|
|
13
15
|
const getPluginDir = (): string => {
|
|
@@ -113,6 +115,13 @@ async function startServer(
|
|
|
113
115
|
HOME: process.env.HOME,
|
|
114
116
|
NODE_ENV: process.env.NODE_ENV,
|
|
115
117
|
CAMOFOX_PORT: String(port),
|
|
118
|
+
CAMOFOX_ADMIN_KEY: process.env.CAMOFOX_ADMIN_KEY,
|
|
119
|
+
CAMOFOX_API_KEY: process.env.CAMOFOX_API_KEY,
|
|
120
|
+
CAMOFOX_COOKIES_DIR: process.env.CAMOFOX_COOKIES_DIR,
|
|
121
|
+
PROXY_HOST: process.env.PROXY_HOST,
|
|
122
|
+
PROXY_PORT: process.env.PROXY_PORT,
|
|
123
|
+
PROXY_USERNAME: process.env.PROXY_USERNAME,
|
|
124
|
+
PROXY_PASSWORD: process.env.PROXY_PASSWORD,
|
|
116
125
|
},
|
|
117
126
|
stdio: ["ignore", "pipe", "pipe"],
|
|
118
127
|
detached: false,
|
|
@@ -192,11 +201,56 @@ function toToolResult(data: unknown): ToolResult {
|
|
|
192
201
|
};
|
|
193
202
|
}
|
|
194
203
|
|
|
204
|
+
function parseNetscapeCookieFile(text: string) {
|
|
205
|
+
// Netscape cookie file format:
|
|
206
|
+
// domain \t includeSubdomains \t path \t secure \t expires \t name \t value
|
|
207
|
+
// HttpOnly cookies are prefixed with: #HttpOnly_
|
|
208
|
+
const cookies: Array<{
|
|
209
|
+
name: string;
|
|
210
|
+
value: string;
|
|
211
|
+
domain: string;
|
|
212
|
+
path: string;
|
|
213
|
+
expires: number;
|
|
214
|
+
httpOnly?: boolean;
|
|
215
|
+
secure?: boolean;
|
|
216
|
+
}> = [];
|
|
217
|
+
|
|
218
|
+
const cleaned = text.replace(/^\uFEFF/, '');
|
|
219
|
+
|
|
220
|
+
for (const rawLine of cleaned.split(/\r?\n/)) {
|
|
221
|
+
const line = rawLine.trim();
|
|
222
|
+
if (!line) continue;
|
|
223
|
+
if (line.startsWith('#') && !line.startsWith('#HttpOnly_')) continue;
|
|
224
|
+
|
|
225
|
+
let httpOnly = false;
|
|
226
|
+
let working = line;
|
|
227
|
+
if (working.startsWith('#HttpOnly_')) {
|
|
228
|
+
httpOnly = true;
|
|
229
|
+
working = working.replace(/^#HttpOnly_/, '');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const parts = working.split('\t');
|
|
233
|
+
if (parts.length < 7) continue;
|
|
234
|
+
|
|
235
|
+
const domain = parts[0];
|
|
236
|
+
const path = parts[2];
|
|
237
|
+
const secure = parts[3].toUpperCase() === 'TRUE';
|
|
238
|
+
const expires = Number(parts[4]);
|
|
239
|
+
const name = parts[5];
|
|
240
|
+
const value = parts.slice(6).join('\t');
|
|
241
|
+
|
|
242
|
+
cookies.push({ name, value, domain, path, expires, httpOnly, secure });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return cookies;
|
|
246
|
+
}
|
|
247
|
+
|
|
195
248
|
export default function register(api: PluginApi) {
|
|
196
249
|
const port = api.config.port || 9377;
|
|
197
250
|
const baseUrl = api.config.url || `http://localhost:${port}`;
|
|
198
251
|
const autoStart = api.config.autoStart !== false; // default true
|
|
199
252
|
const pluginDir = getPluginDir();
|
|
253
|
+
const fallbackUserId = `camofox-${randomUUID()}`;
|
|
200
254
|
|
|
201
255
|
// Auto-start server if configured (default: true)
|
|
202
256
|
if (autoStart) {
|
|
@@ -227,7 +281,7 @@ export default function register(api: PluginApi) {
|
|
|
227
281
|
},
|
|
228
282
|
async execute(_id, params) {
|
|
229
283
|
const sessionKey = ctx.sessionKey || "default";
|
|
230
|
-
const userId = ctx.agentId ||
|
|
284
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
231
285
|
const result = await fetchApi(baseUrl, "/tabs", {
|
|
232
286
|
method: "POST",
|
|
233
287
|
body: JSON.stringify({ ...params, userId, sessionKey }),
|
|
@@ -249,7 +303,7 @@ export default function register(api: PluginApi) {
|
|
|
249
303
|
},
|
|
250
304
|
async execute(_id, params) {
|
|
251
305
|
const { tabId } = params as { tabId: string };
|
|
252
|
-
const userId = ctx.agentId ||
|
|
306
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
253
307
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}`);
|
|
254
308
|
return toToolResult(result);
|
|
255
309
|
},
|
|
@@ -269,7 +323,7 @@ export default function register(api: PluginApi) {
|
|
|
269
323
|
},
|
|
270
324
|
async execute(_id, params) {
|
|
271
325
|
const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
|
|
272
|
-
const userId = ctx.agentId ||
|
|
326
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
273
327
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/click`, {
|
|
274
328
|
method: "POST",
|
|
275
329
|
body: JSON.stringify({ ...rest, userId }),
|
|
@@ -294,7 +348,7 @@ export default function register(api: PluginApi) {
|
|
|
294
348
|
},
|
|
295
349
|
async execute(_id, params) {
|
|
296
350
|
const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
|
|
297
|
-
const userId = ctx.agentId ||
|
|
351
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
298
352
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/type`, {
|
|
299
353
|
method: "POST",
|
|
300
354
|
body: JSON.stringify({ ...rest, userId }),
|
|
@@ -337,7 +391,7 @@ export default function register(api: PluginApi) {
|
|
|
337
391
|
},
|
|
338
392
|
async execute(_id, params) {
|
|
339
393
|
const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
|
|
340
|
-
const userId = ctx.agentId ||
|
|
394
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
341
395
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/navigate`, {
|
|
342
396
|
method: "POST",
|
|
343
397
|
body: JSON.stringify({ ...rest, userId }),
|
|
@@ -360,7 +414,7 @@ export default function register(api: PluginApi) {
|
|
|
360
414
|
},
|
|
361
415
|
async execute(_id, params) {
|
|
362
416
|
const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
|
|
363
|
-
const userId = ctx.agentId ||
|
|
417
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
364
418
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/scroll`, {
|
|
365
419
|
method: "POST",
|
|
366
420
|
body: JSON.stringify({ ...rest, userId }),
|
|
@@ -381,7 +435,7 @@ export default function register(api: PluginApi) {
|
|
|
381
435
|
},
|
|
382
436
|
async execute(_id, params) {
|
|
383
437
|
const { tabId } = params as { tabId: string };
|
|
384
|
-
const userId = ctx.agentId ||
|
|
438
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
385
439
|
const url = `${baseUrl}/tabs/${tabId}/screenshot?userId=${userId}`;
|
|
386
440
|
const res = await fetch(url);
|
|
387
441
|
if (!res.ok) {
|
|
@@ -414,7 +468,7 @@ export default function register(api: PluginApi) {
|
|
|
414
468
|
},
|
|
415
469
|
async execute(_id, params) {
|
|
416
470
|
const { tabId } = params as { tabId: string };
|
|
417
|
-
const userId = ctx.agentId ||
|
|
471
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
418
472
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, {
|
|
419
473
|
method: "DELETE",
|
|
420
474
|
});
|
|
@@ -431,12 +485,84 @@ export default function register(api: PluginApi) {
|
|
|
431
485
|
required: [],
|
|
432
486
|
},
|
|
433
487
|
async execute(_id, _params) {
|
|
434
|
-
const userId = ctx.agentId ||
|
|
488
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
435
489
|
const result = await fetchApi(baseUrl, `/tabs?userId=${userId}`);
|
|
436
490
|
return toToolResult(result);
|
|
437
491
|
},
|
|
438
492
|
}));
|
|
439
493
|
|
|
494
|
+
api.registerTool((ctx: ToolContext) => ({
|
|
495
|
+
name: "camofox_import_cookies",
|
|
496
|
+
description:
|
|
497
|
+
"Import cookies into the current Camoufox user session (Netscape cookie file). Use to authenticate to sites like LinkedIn without interactive login.",
|
|
498
|
+
parameters: {
|
|
499
|
+
type: "object",
|
|
500
|
+
properties: {
|
|
501
|
+
cookiesPath: { type: "string", description: "Path to Netscape-format cookies.txt file" },
|
|
502
|
+
domainSuffix: {
|
|
503
|
+
type: "string",
|
|
504
|
+
description: "Only import cookies whose domain ends with this suffix",
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
required: ["cookiesPath"],
|
|
508
|
+
},
|
|
509
|
+
async execute(_id, params) {
|
|
510
|
+
const { cookiesPath, domainSuffix } = params as {
|
|
511
|
+
cookiesPath: string;
|
|
512
|
+
domainSuffix?: string;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
516
|
+
|
|
517
|
+
const fs = await import("fs/promises");
|
|
518
|
+
|
|
519
|
+
const cookiesDir = resolve(process.env.CAMOFOX_COOKIES_DIR || join(homedir(), ".camofox", "cookies"));
|
|
520
|
+
const resolved = resolve(cookiesDir, cookiesPath);
|
|
521
|
+
if (!resolved.startsWith(cookiesDir + sep)) {
|
|
522
|
+
throw new Error("cookiesPath must be a relative path within the cookies directory");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const stat = await fs.stat(resolved);
|
|
526
|
+
if (stat.size > 5 * 1024 * 1024) {
|
|
527
|
+
throw new Error("Cookie file too large (max 5MB)");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const text = await fs.readFile(resolved, "utf8");
|
|
531
|
+
let cookies = parseNetscapeCookieFile(text);
|
|
532
|
+
if (domainSuffix) {
|
|
533
|
+
cookies = cookies.filter((c) => c.domain.endsWith(domainSuffix));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Translate into Playwright cookie objects
|
|
537
|
+
const pwCookies = cookies.map((c) => ({
|
|
538
|
+
name: c.name,
|
|
539
|
+
value: c.value,
|
|
540
|
+
domain: c.domain,
|
|
541
|
+
path: c.path,
|
|
542
|
+
expires: c.expires,
|
|
543
|
+
httpOnly: !!c.httpOnly,
|
|
544
|
+
secure: !!c.secure,
|
|
545
|
+
}));
|
|
546
|
+
|
|
547
|
+
const apiKey = process.env.CAMOFOX_API_KEY;
|
|
548
|
+
if (!apiKey) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
"CAMOFOX_API_KEY is not set. Cookie import is disabled unless you set CAMOFOX_API_KEY for both the server and the OpenClaw plugin environment."
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const result = await fetchApi(baseUrl, `/sessions/${encodeURIComponent(userId)}/cookies`, {
|
|
555
|
+
method: "POST",
|
|
556
|
+
headers: {
|
|
557
|
+
Authorization: `Bearer ${apiKey}`,
|
|
558
|
+
},
|
|
559
|
+
body: JSON.stringify({ cookies: pwCookies }),
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
return toToolResult({ imported: pwCookies.length, userId, result });
|
|
563
|
+
},
|
|
564
|
+
}));
|
|
565
|
+
|
|
440
566
|
api.registerCommand({
|
|
441
567
|
name: "camofox",
|
|
442
568
|
description: "Camoufox browser server control (status, start, stop)",
|
package/server.js
CHANGED
|
@@ -5,8 +5,149 @@ const crypto = require('crypto');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const { expandMacro } = require('./lib/macros');
|
|
7
7
|
|
|
8
|
+
// --- Structured logging ---
|
|
9
|
+
function log(level, msg, fields = {}) {
|
|
10
|
+
const entry = {
|
|
11
|
+
ts: new Date().toISOString(),
|
|
12
|
+
level,
|
|
13
|
+
msg,
|
|
14
|
+
...fields,
|
|
15
|
+
};
|
|
16
|
+
const line = JSON.stringify(entry);
|
|
17
|
+
if (level === 'error') {
|
|
18
|
+
process.stderr.write(line + '\n');
|
|
19
|
+
} else {
|
|
20
|
+
process.stdout.write(line + '\n');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
const app = express();
|
|
9
|
-
app.use(express.json({ limit: '
|
|
25
|
+
app.use(express.json({ limit: '100kb' }));
|
|
26
|
+
|
|
27
|
+
// Request logging middleware
|
|
28
|
+
app.use((req, res, next) => {
|
|
29
|
+
if (req.path === '/health') return next();
|
|
30
|
+
const reqId = crypto.randomUUID().slice(0, 8);
|
|
31
|
+
req.reqId = reqId;
|
|
32
|
+
req.startTime = Date.now();
|
|
33
|
+
const userId = req.body?.userId || req.query?.userId || '-';
|
|
34
|
+
log('info', 'req', { reqId, method: req.method, path: req.path, userId });
|
|
35
|
+
const origEnd = res.end.bind(res);
|
|
36
|
+
res.end = function (...args) {
|
|
37
|
+
const ms = Date.now() - req.startTime;
|
|
38
|
+
log('info', 'res', { reqId, status: res.statusCode, ms });
|
|
39
|
+
return origEnd(...args);
|
|
40
|
+
};
|
|
41
|
+
next();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const ALLOWED_URL_SCHEMES = ['http:', 'https:'];
|
|
45
|
+
|
|
46
|
+
function timingSafeCompare(a, b) {
|
|
47
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
48
|
+
const bufA = Buffer.from(a);
|
|
49
|
+
const bufB = Buffer.from(b);
|
|
50
|
+
if (bufA.length !== bufB.length) {
|
|
51
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeError(err) {
|
|
58
|
+
if (process.env.NODE_ENV === 'production') {
|
|
59
|
+
log('error', 'internal error', { error: err.message, stack: err.stack });
|
|
60
|
+
return 'Internal server error';
|
|
61
|
+
}
|
|
62
|
+
return err.message;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateUrl(url) {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = new URL(url);
|
|
68
|
+
if (!ALLOWED_URL_SCHEMES.includes(parsed.protocol)) {
|
|
69
|
+
return `Blocked URL scheme: ${parsed.protocol} (only http/https allowed)`;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
} catch {
|
|
73
|
+
return `Invalid URL: ${url}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Import cookies into a user's browser context (Playwright cookies format)
|
|
78
|
+
// POST /sessions/:userId/cookies { cookies: Cookie[] }
|
|
79
|
+
//
|
|
80
|
+
// SECURITY:
|
|
81
|
+
// Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
|
|
82
|
+
// This endpoint is DISABLED unless CAMOFOX_API_KEY is set.
|
|
83
|
+
// When enabled, caller must send: Authorization: Bearer <CAMOFOX_API_KEY>
|
|
84
|
+
app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
|
|
85
|
+
try {
|
|
86
|
+
const apiKey = process.env.CAMOFOX_API_KEY;
|
|
87
|
+
if (!apiKey) {
|
|
88
|
+
return res.status(403).json({
|
|
89
|
+
error: 'Cookie import is disabled. Set CAMOFOX_API_KEY to enable this endpoint.',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const auth = String(req.headers['authorization'] || '');
|
|
94
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
95
|
+
if (!match || !timingSafeCompare(match[1], apiKey)) {
|
|
96
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const userId = req.params.userId;
|
|
100
|
+
if (!req.body || !('cookies' in req.body)) {
|
|
101
|
+
return res.status(400).json({ error: 'Missing "cookies" field in request body' });
|
|
102
|
+
}
|
|
103
|
+
const cookies = req.body.cookies;
|
|
104
|
+
if (!Array.isArray(cookies)) {
|
|
105
|
+
return res.status(400).json({ error: 'cookies must be an array' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (cookies.length > 500) {
|
|
109
|
+
return res.status(400).json({ error: 'Too many cookies. Maximum 500 per request.' });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const invalid = [];
|
|
113
|
+
for (let i = 0; i < cookies.length; i++) {
|
|
114
|
+
const c = cookies[i];
|
|
115
|
+
const missing = [];
|
|
116
|
+
if (!c || typeof c !== 'object') {
|
|
117
|
+
invalid.push({ index: i, error: 'cookie must be an object' });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (typeof c.name !== 'string' || !c.name) missing.push('name');
|
|
121
|
+
if (typeof c.value !== 'string') missing.push('value');
|
|
122
|
+
if (typeof c.domain !== 'string' || !c.domain) missing.push('domain');
|
|
123
|
+
if (missing.length) invalid.push({ index: i, missing });
|
|
124
|
+
}
|
|
125
|
+
if (invalid.length) {
|
|
126
|
+
return res.status(400).json({
|
|
127
|
+
error: 'Invalid cookie objects: each cookie must include name, value, and domain',
|
|
128
|
+
invalid,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const allowedFields = ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite'];
|
|
133
|
+
const sanitized = cookies.map(c => {
|
|
134
|
+
const clean = {};
|
|
135
|
+
for (const k of allowedFields) {
|
|
136
|
+
if (c[k] !== undefined) clean[k] = c[k];
|
|
137
|
+
}
|
|
138
|
+
return clean;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const session = await getSession(userId);
|
|
142
|
+
await session.context.addCookies(sanitized);
|
|
143
|
+
const result = { ok: true, userId: String(userId), count: sanitized.length };
|
|
144
|
+
log('info', 'cookies imported', { reqId: req.reqId, userId: String(userId), count: sanitized.length });
|
|
145
|
+
res.json(result);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
log('error', 'cookie import failed', { reqId: req.reqId, error: err.message });
|
|
148
|
+
res.status(500).json({ error: safeError(err) });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
10
151
|
|
|
11
152
|
let browser = null;
|
|
12
153
|
// userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
|
|
@@ -16,18 +157,8 @@ const sessions = new Map();
|
|
|
16
157
|
|
|
17
158
|
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
|
|
18
159
|
const MAX_SNAPSHOT_NODES = 500;
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
function logResponse(endpoint, data) {
|
|
22
|
-
if (!DEBUG_RESPONSES) return;
|
|
23
|
-
let logData = data;
|
|
24
|
-
// Truncate snapshot for readability
|
|
25
|
-
if (data && data.snapshot) {
|
|
26
|
-
const snap = data.snapshot;
|
|
27
|
-
logData = { ...data, snapshot: `[${snap.length} chars] ${snap.slice(0, 300)}...` };
|
|
28
|
-
}
|
|
29
|
-
console.log(`📤 ${endpoint} ->`, JSON.stringify(logData, null, 2));
|
|
30
|
-
}
|
|
160
|
+
const MAX_SESSIONS = 50;
|
|
161
|
+
const MAX_TABS_PER_SESSION = 10;
|
|
31
162
|
|
|
32
163
|
// Per-tab locks to serialize operations on the same tab
|
|
33
164
|
// tabId -> Promise (the currently executing operation)
|
|
@@ -66,20 +197,43 @@ function getHostOS() {
|
|
|
66
197
|
return 'linux';
|
|
67
198
|
}
|
|
68
199
|
|
|
200
|
+
function buildProxyConfig() {
|
|
201
|
+
const host = process.env.PROXY_HOST;
|
|
202
|
+
const port = process.env.PROXY_PORT;
|
|
203
|
+
const username = process.env.PROXY_USERNAME;
|
|
204
|
+
const password = process.env.PROXY_PASSWORD;
|
|
205
|
+
|
|
206
|
+
if (!host || !port) {
|
|
207
|
+
log('info', 'no proxy configured');
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
log('info', 'proxy configured', { host, port });
|
|
212
|
+
return {
|
|
213
|
+
server: `http://${host}:${port}`,
|
|
214
|
+
username,
|
|
215
|
+
password,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
69
219
|
async function ensureBrowser() {
|
|
70
220
|
if (!browser) {
|
|
71
221
|
const hostOS = getHostOS();
|
|
72
|
-
|
|
222
|
+
const proxy = buildProxyConfig();
|
|
223
|
+
|
|
224
|
+
log('info', 'launching camoufox', { hostOS, geoip: !!proxy });
|
|
73
225
|
|
|
74
226
|
const options = await launchOptions({
|
|
75
227
|
headless: true,
|
|
76
228
|
os: hostOS,
|
|
77
229
|
humanize: true,
|
|
78
230
|
enable_cache: true,
|
|
231
|
+
proxy: proxy,
|
|
232
|
+
geoip: !!proxy,
|
|
79
233
|
});
|
|
80
234
|
|
|
81
235
|
browser = await firefox.launch(options);
|
|
82
|
-
|
|
236
|
+
log('info', 'camoufox launched');
|
|
83
237
|
}
|
|
84
238
|
return browser;
|
|
85
239
|
}
|
|
@@ -93,18 +247,26 @@ async function getSession(userId) {
|
|
|
93
247
|
const key = normalizeUserId(userId);
|
|
94
248
|
let session = sessions.get(key);
|
|
95
249
|
if (!session) {
|
|
250
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
251
|
+
throw new Error('Maximum concurrent sessions reached');
|
|
252
|
+
}
|
|
96
253
|
const b = await ensureBrowser();
|
|
97
|
-
const
|
|
254
|
+
const contextOptions = {
|
|
98
255
|
viewport: { width: 1280, height: 720 },
|
|
99
|
-
locale: 'en-US',
|
|
100
|
-
timezoneId: 'America/Los_Angeles',
|
|
101
|
-
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
|
102
256
|
permissions: ['geolocation'],
|
|
103
|
-
}
|
|
257
|
+
};
|
|
258
|
+
// When geoip is active (proxy configured), camoufox auto-configures
|
|
259
|
+
// locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
|
|
260
|
+
if (!process.env.PROXY_HOST) {
|
|
261
|
+
contextOptions.locale = 'en-US';
|
|
262
|
+
contextOptions.timezoneId = 'America/Los_Angeles';
|
|
263
|
+
contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
|
|
264
|
+
}
|
|
265
|
+
const context = await b.newContext(contextOptions);
|
|
104
266
|
|
|
105
267
|
session = { context, tabGroups: new Map(), lastAccess: Date.now() };
|
|
106
268
|
sessions.set(key, session);
|
|
107
|
-
|
|
269
|
+
log('info', 'session created', { userId: key });
|
|
108
270
|
}
|
|
109
271
|
session.lastAccess = Date.now();
|
|
110
272
|
return session;
|
|
@@ -146,7 +308,7 @@ async function waitForPageReady(page, options = {}) {
|
|
|
146
308
|
|
|
147
309
|
if (waitForNetwork) {
|
|
148
310
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
|
149
|
-
|
|
311
|
+
log('warn', 'networkidle timeout, continuing');
|
|
150
312
|
});
|
|
151
313
|
}
|
|
152
314
|
|
|
@@ -167,7 +329,7 @@ async function waitForPageReady(page, options = {}) {
|
|
|
167
329
|
await new Promise(r => setTimeout(r, 250));
|
|
168
330
|
}
|
|
169
331
|
}).catch(() => {
|
|
170
|
-
|
|
332
|
+
log('warn', 'hydration wait failed, continuing');
|
|
171
333
|
});
|
|
172
334
|
|
|
173
335
|
await page.waitForTimeout(200);
|
|
@@ -177,7 +339,7 @@ async function waitForPageReady(page, options = {}) {
|
|
|
177
339
|
|
|
178
340
|
return true;
|
|
179
341
|
} catch (err) {
|
|
180
|
-
|
|
342
|
+
log('warn', 'page ready failed', { error: err.message });
|
|
181
343
|
return false;
|
|
182
344
|
}
|
|
183
345
|
}
|
|
@@ -217,7 +379,7 @@ async function dismissConsentDialogs(page) {
|
|
|
217
379
|
const button = page.locator(selector).first();
|
|
218
380
|
if (await button.isVisible({ timeout: 100 })) {
|
|
219
381
|
await button.click({ timeout: 1000 }).catch(() => {});
|
|
220
|
-
|
|
382
|
+
log('info', 'dismissed consent dialog', { selector });
|
|
221
383
|
await page.waitForTimeout(300); // Brief pause after dismiss
|
|
222
384
|
break; // Only dismiss one dialog per page load
|
|
223
385
|
}
|
|
@@ -231,7 +393,7 @@ async function buildRefs(page) {
|
|
|
231
393
|
const refs = new Map();
|
|
232
394
|
|
|
233
395
|
if (!page || page.isClosed()) {
|
|
234
|
-
|
|
396
|
+
log('warn', 'buildRefs: page closed or invalid');
|
|
235
397
|
return refs;
|
|
236
398
|
}
|
|
237
399
|
|
|
@@ -244,7 +406,7 @@ async function buildRefs(page) {
|
|
|
244
406
|
try {
|
|
245
407
|
ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
|
|
246
408
|
} catch (err) {
|
|
247
|
-
|
|
409
|
+
log('warn', 'ariaSnapshot failed, retrying');
|
|
248
410
|
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
249
411
|
ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
|
|
250
412
|
}
|
|
@@ -270,7 +432,7 @@ async function buildRefs(page) {
|
|
|
270
432
|
}).catch(() => []);
|
|
271
433
|
|
|
272
434
|
if (!ariaYaml) {
|
|
273
|
-
|
|
435
|
+
log('warn', 'buildRefs: no aria snapshot');
|
|
274
436
|
return refs;
|
|
275
437
|
}
|
|
276
438
|
|
|
@@ -353,11 +515,10 @@ app.get('/health', async (req, res) => {
|
|
|
353
515
|
res.json({
|
|
354
516
|
ok: true,
|
|
355
517
|
engine: 'camoufox',
|
|
356
|
-
sessions: sessions.size,
|
|
357
518
|
browserConnected: b.isConnected()
|
|
358
519
|
});
|
|
359
520
|
} catch (err) {
|
|
360
|
-
res.status(500).json({ ok: false, error: err
|
|
521
|
+
res.status(500).json({ ok: false, error: safeError(err) });
|
|
361
522
|
}
|
|
362
523
|
});
|
|
363
524
|
|
|
@@ -372,6 +533,13 @@ app.post('/tabs', async (req, res) => {
|
|
|
372
533
|
}
|
|
373
534
|
|
|
374
535
|
const session = await getSession(userId);
|
|
536
|
+
|
|
537
|
+
let totalTabs = 0;
|
|
538
|
+
for (const group of session.tabGroups.values()) totalTabs += group.size;
|
|
539
|
+
if (totalTabs >= MAX_TABS_PER_SESSION) {
|
|
540
|
+
return res.status(429).json({ error: 'Maximum tabs per session reached' });
|
|
541
|
+
}
|
|
542
|
+
|
|
375
543
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
376
544
|
|
|
377
545
|
const page = await session.context.newPage();
|
|
@@ -380,15 +548,17 @@ app.post('/tabs', async (req, res) => {
|
|
|
380
548
|
group.set(tabId, tabState);
|
|
381
549
|
|
|
382
550
|
if (url) {
|
|
551
|
+
const urlErr = validateUrl(url);
|
|
552
|
+
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
383
553
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
384
554
|
tabState.visitedUrls.add(url);
|
|
385
555
|
}
|
|
386
556
|
|
|
387
|
-
|
|
557
|
+
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
|
|
388
558
|
res.json({ tabId, url: page.url() });
|
|
389
559
|
} catch (err) {
|
|
390
|
-
|
|
391
|
-
res.status(500).json({ error: err
|
|
560
|
+
log('error', 'tab create failed', { reqId: req.reqId, error: err.message });
|
|
561
|
+
res.status(500).json({ error: safeError(err) });
|
|
392
562
|
}
|
|
393
563
|
});
|
|
394
564
|
|
|
@@ -414,6 +584,9 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
414
584
|
return res.status(400).json({ error: 'url or macro required' });
|
|
415
585
|
}
|
|
416
586
|
|
|
587
|
+
const urlErr = validateUrl(targetUrl);
|
|
588
|
+
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
589
|
+
|
|
417
590
|
// Serialize navigation operations on the same tab
|
|
418
591
|
const result = await withTabLock(tabId, async () => {
|
|
419
592
|
await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
@@ -422,11 +595,11 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
422
595
|
return { ok: true, url: tabState.page.url() };
|
|
423
596
|
});
|
|
424
597
|
|
|
425
|
-
|
|
598
|
+
log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
|
|
426
599
|
res.json(result);
|
|
427
600
|
} catch (err) {
|
|
428
|
-
|
|
429
|
-
res.status(500).json({ error: err
|
|
601
|
+
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
|
|
602
|
+
res.status(500).json({ error: safeError(err) });
|
|
430
603
|
}
|
|
431
604
|
});
|
|
432
605
|
|
|
@@ -498,11 +671,11 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
498
671
|
snapshot: annotatedYaml,
|
|
499
672
|
refsCount: tabState.refs.size
|
|
500
673
|
};
|
|
501
|
-
|
|
674
|
+
log('info', 'snapshot', { reqId: req.reqId, tabId: req.params.tabId, url: result.url, snapshotLen: result.snapshot?.length, refsCount: result.refsCount });
|
|
502
675
|
res.json(result);
|
|
503
676
|
} catch (err) {
|
|
504
|
-
|
|
505
|
-
res.status(500).json({ error: err
|
|
677
|
+
log('error', 'snapshot failed', { reqId: req.reqId, tabId: req.params.tabId, error: err.message });
|
|
678
|
+
res.status(500).json({ error: safeError(err) });
|
|
506
679
|
}
|
|
507
680
|
});
|
|
508
681
|
|
|
@@ -519,8 +692,8 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
|
|
|
519
692
|
|
|
520
693
|
res.json({ ok: true, ready });
|
|
521
694
|
} catch (err) {
|
|
522
|
-
|
|
523
|
-
res.status(500).json({ error: err
|
|
695
|
+
log('error', 'wait failed', { reqId: req.reqId, error: err.message });
|
|
696
|
+
res.status(500).json({ error: safeError(err) });
|
|
524
697
|
}
|
|
525
698
|
});
|
|
526
699
|
|
|
@@ -560,7 +733,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
560
733
|
await tabState.page.waitForTimeout(50);
|
|
561
734
|
await tabState.page.mouse.up();
|
|
562
735
|
|
|
563
|
-
|
|
736
|
+
log('info', 'mouse sequence dispatched', { x: x.toFixed(0), y: y.toFixed(0) });
|
|
564
737
|
};
|
|
565
738
|
|
|
566
739
|
const doClick = async (locatorOrSelector, isLocator) => {
|
|
@@ -572,17 +745,17 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
572
745
|
} catch (err) {
|
|
573
746
|
// Fallback 1: If intercepted by overlay, retry with force
|
|
574
747
|
if (err.message.includes('intercepts pointer events')) {
|
|
575
|
-
|
|
748
|
+
log('warn', 'click intercepted, retrying with force');
|
|
576
749
|
try {
|
|
577
750
|
await locator.click({ timeout: 5000, force: true });
|
|
578
751
|
} catch (forceErr) {
|
|
579
752
|
// Fallback 2: Full mouse event sequence for stubborn JS handlers
|
|
580
|
-
|
|
753
|
+
log('warn', 'force click failed, trying mouse sequence');
|
|
581
754
|
await dispatchMouseSequence(locator);
|
|
582
755
|
}
|
|
583
756
|
} else if (err.message.includes('not visible') || err.message.includes('timeout')) {
|
|
584
757
|
// Fallback 2: Element not responding to click, try mouse sequence
|
|
585
|
-
|
|
758
|
+
log('warn', 'click timeout, trying mouse sequence');
|
|
586
759
|
await dispatchMouseSequence(locator);
|
|
587
760
|
} else {
|
|
588
761
|
throw err;
|
|
@@ -609,11 +782,11 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
609
782
|
return { ok: true, url: newUrl };
|
|
610
783
|
});
|
|
611
784
|
|
|
612
|
-
|
|
785
|
+
log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
|
|
613
786
|
res.json(result);
|
|
614
787
|
} catch (err) {
|
|
615
|
-
|
|
616
|
-
res.status(500).json({ error: err
|
|
788
|
+
log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
|
|
789
|
+
res.status(500).json({ error: safeError(err) });
|
|
617
790
|
}
|
|
618
791
|
});
|
|
619
792
|
|
|
@@ -646,8 +819,8 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
646
819
|
|
|
647
820
|
res.json({ ok: true });
|
|
648
821
|
} catch (err) {
|
|
649
|
-
|
|
650
|
-
res.status(500).json({ error: err
|
|
822
|
+
log('error', 'type failed', { reqId: req.reqId, error: err.message });
|
|
823
|
+
res.status(500).json({ error: safeError(err) });
|
|
651
824
|
}
|
|
652
825
|
});
|
|
653
826
|
|
|
@@ -670,8 +843,8 @@ app.post('/tabs/:tabId/press', async (req, res) => {
|
|
|
670
843
|
|
|
671
844
|
res.json({ ok: true });
|
|
672
845
|
} catch (err) {
|
|
673
|
-
|
|
674
|
-
res.status(500).json({ error: err
|
|
846
|
+
log('error', 'press failed', { reqId: req.reqId, error: err.message });
|
|
847
|
+
res.status(500).json({ error: safeError(err) });
|
|
675
848
|
}
|
|
676
849
|
});
|
|
677
850
|
|
|
@@ -692,8 +865,8 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
|
692
865
|
|
|
693
866
|
res.json({ ok: true });
|
|
694
867
|
} catch (err) {
|
|
695
|
-
|
|
696
|
-
res.status(500).json({ error: err
|
|
868
|
+
log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
|
|
869
|
+
res.status(500).json({ error: safeError(err) });
|
|
697
870
|
}
|
|
698
871
|
});
|
|
699
872
|
|
|
@@ -718,8 +891,8 @@ app.post('/tabs/:tabId/back', async (req, res) => {
|
|
|
718
891
|
|
|
719
892
|
res.json(result);
|
|
720
893
|
} catch (err) {
|
|
721
|
-
|
|
722
|
-
res.status(500).json({ error: err
|
|
894
|
+
log('error', 'back failed', { reqId: req.reqId, error: err.message });
|
|
895
|
+
res.status(500).json({ error: safeError(err) });
|
|
723
896
|
}
|
|
724
897
|
});
|
|
725
898
|
|
|
@@ -744,8 +917,8 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
|
|
|
744
917
|
|
|
745
918
|
res.json(result);
|
|
746
919
|
} catch (err) {
|
|
747
|
-
|
|
748
|
-
res.status(500).json({ error: err
|
|
920
|
+
log('error', 'forward failed', { reqId: req.reqId, error: err.message });
|
|
921
|
+
res.status(500).json({ error: safeError(err) });
|
|
749
922
|
}
|
|
750
923
|
});
|
|
751
924
|
|
|
@@ -770,8 +943,8 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
|
|
|
770
943
|
|
|
771
944
|
res.json(result);
|
|
772
945
|
} catch (err) {
|
|
773
|
-
|
|
774
|
-
res.status(500).json({ error: err
|
|
946
|
+
log('error', 'refresh failed', { reqId: req.reqId, error: err.message });
|
|
947
|
+
res.status(500).json({ error: safeError(err) });
|
|
775
948
|
}
|
|
776
949
|
});
|
|
777
950
|
|
|
@@ -784,7 +957,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
784
957
|
const session = sessions.get(normalizeUserId(userId));
|
|
785
958
|
const found = session && findTab(session, req.params.tabId);
|
|
786
959
|
if (!found) {
|
|
787
|
-
|
|
960
|
+
log('warn', 'links: tab not found', { reqId: req.reqId, tabId: req.params.tabId, userId, hasSession: !!session });
|
|
788
961
|
return res.status(404).json({ error: 'Tab not found' });
|
|
789
962
|
}
|
|
790
963
|
|
|
@@ -811,8 +984,8 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
811
984
|
pagination: { total, offset, limit, hasMore: offset + limit < total }
|
|
812
985
|
});
|
|
813
986
|
} catch (err) {
|
|
814
|
-
|
|
815
|
-
res.status(500).json({ error: err
|
|
987
|
+
log('error', 'links failed', { reqId: req.reqId, error: err.message });
|
|
988
|
+
res.status(500).json({ error: safeError(err) });
|
|
816
989
|
}
|
|
817
990
|
});
|
|
818
991
|
|
|
@@ -830,8 +1003,8 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
|
|
|
830
1003
|
res.set('Content-Type', 'image/png');
|
|
831
1004
|
res.send(buffer);
|
|
832
1005
|
} catch (err) {
|
|
833
|
-
|
|
834
|
-
res.status(500).json({ error: err
|
|
1006
|
+
log('error', 'screenshot failed', { reqId: req.reqId, error: err.message });
|
|
1007
|
+
res.status(500).json({ error: safeError(err) });
|
|
835
1008
|
}
|
|
836
1009
|
});
|
|
837
1010
|
|
|
@@ -854,8 +1027,8 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
|
|
|
854
1027
|
refsCount: tabState.refs.size
|
|
855
1028
|
});
|
|
856
1029
|
} catch (err) {
|
|
857
|
-
|
|
858
|
-
res.status(500).json({ error: err
|
|
1030
|
+
log('error', 'stats failed', { reqId: req.reqId, error: err.message });
|
|
1031
|
+
res.status(500).json({ error: safeError(err) });
|
|
859
1032
|
}
|
|
860
1033
|
});
|
|
861
1034
|
|
|
@@ -868,15 +1041,16 @@ app.delete('/tabs/:tabId', async (req, res) => {
|
|
|
868
1041
|
if (found) {
|
|
869
1042
|
await found.tabState.page.close();
|
|
870
1043
|
found.group.delete(req.params.tabId);
|
|
1044
|
+
tabLocks.delete(req.params.tabId);
|
|
871
1045
|
if (found.group.size === 0) {
|
|
872
1046
|
session.tabGroups.delete(found.listItemId);
|
|
873
1047
|
}
|
|
874
|
-
|
|
1048
|
+
log('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
|
|
875
1049
|
}
|
|
876
1050
|
res.json({ ok: true });
|
|
877
1051
|
} catch (err) {
|
|
878
|
-
|
|
879
|
-
res.status(500).json({ error: err
|
|
1052
|
+
log('error', 'tab close failed', { reqId: req.reqId, error: err.message });
|
|
1053
|
+
res.status(500).json({ error: safeError(err) });
|
|
880
1054
|
}
|
|
881
1055
|
});
|
|
882
1056
|
|
|
@@ -889,31 +1063,32 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
|
|
|
889
1063
|
if (group) {
|
|
890
1064
|
for (const [tabId, tabState] of group) {
|
|
891
1065
|
await tabState.page.close().catch(() => {});
|
|
1066
|
+
tabLocks.delete(tabId);
|
|
892
1067
|
}
|
|
893
1068
|
session.tabGroups.delete(req.params.listItemId);
|
|
894
|
-
|
|
1069
|
+
log('info', 'tab group closed', { reqId: req.reqId, listItemId: req.params.listItemId, userId });
|
|
895
1070
|
}
|
|
896
1071
|
res.json({ ok: true });
|
|
897
1072
|
} catch (err) {
|
|
898
|
-
|
|
899
|
-
res.status(500).json({ error: err
|
|
1073
|
+
log('error', 'tab group close failed', { reqId: req.reqId, error: err.message });
|
|
1074
|
+
res.status(500).json({ error: safeError(err) });
|
|
900
1075
|
}
|
|
901
1076
|
});
|
|
902
1077
|
|
|
903
1078
|
// Close session
|
|
904
1079
|
app.delete('/sessions/:userId', async (req, res) => {
|
|
905
1080
|
try {
|
|
906
|
-
const userId = req.params.userId;
|
|
907
|
-
const session = sessions.get(
|
|
1081
|
+
const userId = normalizeUserId(req.params.userId);
|
|
1082
|
+
const session = sessions.get(userId);
|
|
908
1083
|
if (session) {
|
|
909
1084
|
await session.context.close();
|
|
910
1085
|
sessions.delete(userId);
|
|
911
|
-
|
|
1086
|
+
log('info', 'session closed', { userId });
|
|
912
1087
|
}
|
|
913
1088
|
res.json({ ok: true });
|
|
914
1089
|
} catch (err) {
|
|
915
|
-
|
|
916
|
-
res.status(500).json({ error: err
|
|
1090
|
+
log('error', 'session close failed', { error: err.message });
|
|
1091
|
+
res.status(500).json({ error: safeError(err) });
|
|
917
1092
|
}
|
|
918
1093
|
});
|
|
919
1094
|
|
|
@@ -924,7 +1099,7 @@ setInterval(() => {
|
|
|
924
1099
|
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
|
925
1100
|
session.context.close().catch(() => {});
|
|
926
1101
|
sessions.delete(userId);
|
|
927
|
-
|
|
1102
|
+
log('info', 'session expired', { userId });
|
|
928
1103
|
}
|
|
929
1104
|
}
|
|
930
1105
|
}, 60_000);
|
|
@@ -943,11 +1118,10 @@ app.get('/', async (req, res) => {
|
|
|
943
1118
|
enabled: true,
|
|
944
1119
|
running: b.isConnected(),
|
|
945
1120
|
engine: 'camoufox',
|
|
946
|
-
sessions: sessions.size,
|
|
947
1121
|
browserConnected: b.isConnected()
|
|
948
1122
|
});
|
|
949
1123
|
} catch (err) {
|
|
950
|
-
res.status(500).json({ ok: false, error: err
|
|
1124
|
+
res.status(500).json({ ok: false, error: safeError(err) });
|
|
951
1125
|
}
|
|
952
1126
|
});
|
|
953
1127
|
|
|
@@ -976,20 +1150,33 @@ app.get('/tabs', async (req, res) => {
|
|
|
976
1150
|
|
|
977
1151
|
res.json({ running: true, tabs });
|
|
978
1152
|
} catch (err) {
|
|
979
|
-
|
|
980
|
-
res.status(500).json({ error: err
|
|
1153
|
+
log('error', 'list tabs failed', { reqId: req.reqId, error: err.message });
|
|
1154
|
+
res.status(500).json({ error: safeError(err) });
|
|
981
1155
|
}
|
|
982
1156
|
});
|
|
983
1157
|
|
|
984
1158
|
// POST /tabs/open - Open tab (alias for POST /tabs, OpenClaw format)
|
|
985
1159
|
app.post('/tabs/open', async (req, res) => {
|
|
986
1160
|
try {
|
|
987
|
-
const { url, userId
|
|
1161
|
+
const { url, userId, listItemId = 'default' } = req.body;
|
|
1162
|
+
if (!userId) {
|
|
1163
|
+
return res.status(400).json({ error: 'userId is required' });
|
|
1164
|
+
}
|
|
988
1165
|
if (!url) {
|
|
989
1166
|
return res.status(400).json({ error: 'url is required' });
|
|
990
1167
|
}
|
|
991
1168
|
|
|
1169
|
+
const urlErr = validateUrl(url);
|
|
1170
|
+
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
1171
|
+
|
|
992
1172
|
const session = await getSession(userId);
|
|
1173
|
+
|
|
1174
|
+
let totalTabs = 0;
|
|
1175
|
+
for (const g of session.tabGroups.values()) totalTabs += g.size;
|
|
1176
|
+
if (totalTabs >= MAX_TABS_PER_SESSION) {
|
|
1177
|
+
return res.status(429).json({ error: 'Maximum tabs per session reached' });
|
|
1178
|
+
}
|
|
1179
|
+
|
|
993
1180
|
const group = getTabGroup(session, listItemId);
|
|
994
1181
|
|
|
995
1182
|
const page = await session.context.newPage();
|
|
@@ -1000,7 +1187,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1000
1187
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1001
1188
|
tabState.visitedUrls.add(url);
|
|
1002
1189
|
|
|
1003
|
-
|
|
1190
|
+
log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
|
|
1004
1191
|
res.json({
|
|
1005
1192
|
ok: true,
|
|
1006
1193
|
targetId: tabId,
|
|
@@ -1009,8 +1196,8 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1009
1196
|
title: await page.title().catch(() => '')
|
|
1010
1197
|
});
|
|
1011
1198
|
} catch (err) {
|
|
1012
|
-
|
|
1013
|
-
res.status(500).json({ error: err
|
|
1199
|
+
log('error', 'openclaw tab open failed', { reqId: req.reqId, error: err.message });
|
|
1200
|
+
res.status(500).json({ error: safeError(err) });
|
|
1014
1201
|
}
|
|
1015
1202
|
});
|
|
1016
1203
|
|
|
@@ -1020,13 +1207,17 @@ app.post('/start', async (req, res) => {
|
|
|
1020
1207
|
await ensureBrowser();
|
|
1021
1208
|
res.json({ ok: true, profile: 'camoufox' });
|
|
1022
1209
|
} catch (err) {
|
|
1023
|
-
res.status(500).json({ ok: false, error: err
|
|
1210
|
+
res.status(500).json({ ok: false, error: safeError(err) });
|
|
1024
1211
|
}
|
|
1025
1212
|
});
|
|
1026
1213
|
|
|
1027
1214
|
// POST /stop - Stop browser (OpenClaw expects this)
|
|
1028
1215
|
app.post('/stop', async (req, res) => {
|
|
1029
1216
|
try {
|
|
1217
|
+
const adminKey = req.headers['x-admin-key'];
|
|
1218
|
+
if (!adminKey || !timingSafeCompare(adminKey, process.env.CAMOFOX_ADMIN_KEY || '')) {
|
|
1219
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
1220
|
+
}
|
|
1030
1221
|
if (browser) {
|
|
1031
1222
|
await browser.close().catch(() => {});
|
|
1032
1223
|
browser = null;
|
|
@@ -1034,18 +1225,24 @@ app.post('/stop', async (req, res) => {
|
|
|
1034
1225
|
sessions.clear();
|
|
1035
1226
|
res.json({ ok: true, stopped: true, profile: 'camoufox' });
|
|
1036
1227
|
} catch (err) {
|
|
1037
|
-
res.status(500).json({ ok: false, error: err
|
|
1228
|
+
res.status(500).json({ ok: false, error: safeError(err) });
|
|
1038
1229
|
}
|
|
1039
1230
|
});
|
|
1040
1231
|
|
|
1041
1232
|
// POST /navigate - Navigate (OpenClaw format with targetId in body)
|
|
1042
1233
|
app.post('/navigate', async (req, res) => {
|
|
1043
1234
|
try {
|
|
1044
|
-
const { targetId, url, userId
|
|
1235
|
+
const { targetId, url, userId } = req.body;
|
|
1236
|
+
if (!userId) {
|
|
1237
|
+
return res.status(400).json({ error: 'userId is required' });
|
|
1238
|
+
}
|
|
1045
1239
|
if (!url) {
|
|
1046
1240
|
return res.status(400).json({ error: 'url is required' });
|
|
1047
1241
|
}
|
|
1048
1242
|
|
|
1243
|
+
const urlErr = validateUrl(url);
|
|
1244
|
+
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
1245
|
+
|
|
1049
1246
|
const session = sessions.get(normalizeUserId(userId));
|
|
1050
1247
|
const found = session && findTab(session, targetId);
|
|
1051
1248
|
if (!found) {
|
|
@@ -1064,15 +1261,18 @@ app.post('/navigate', async (req, res) => {
|
|
|
1064
1261
|
|
|
1065
1262
|
res.json(result);
|
|
1066
1263
|
} catch (err) {
|
|
1067
|
-
|
|
1068
|
-
res.status(500).json({ error: err
|
|
1264
|
+
log('error', 'openclaw navigate failed', { reqId: req.reqId, error: err.message });
|
|
1265
|
+
res.status(500).json({ error: safeError(err) });
|
|
1069
1266
|
}
|
|
1070
1267
|
});
|
|
1071
1268
|
|
|
1072
1269
|
// GET /snapshot - Snapshot (OpenClaw format with query params)
|
|
1073
1270
|
app.get('/snapshot', async (req, res) => {
|
|
1074
1271
|
try {
|
|
1075
|
-
const { targetId, userId
|
|
1272
|
+
const { targetId, userId, format = 'text' } = req.query;
|
|
1273
|
+
if (!userId) {
|
|
1274
|
+
return res.status(400).json({ error: 'userId is required' });
|
|
1275
|
+
}
|
|
1076
1276
|
|
|
1077
1277
|
const session = sessions.get(normalizeUserId(userId));
|
|
1078
1278
|
const found = session && findTab(session, targetId);
|
|
@@ -1119,8 +1319,8 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1119
1319
|
refsCount: tabState.refs.size
|
|
1120
1320
|
});
|
|
1121
1321
|
} catch (err) {
|
|
1122
|
-
|
|
1123
|
-
res.status(500).json({ error: err
|
|
1322
|
+
log('error', 'openclaw snapshot failed', { reqId: req.reqId, error: err.message });
|
|
1323
|
+
res.status(500).json({ error: safeError(err) });
|
|
1124
1324
|
}
|
|
1125
1325
|
});
|
|
1126
1326
|
|
|
@@ -1128,7 +1328,10 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1128
1328
|
// Routes to click/type/scroll/press/etc based on 'kind' parameter
|
|
1129
1329
|
app.post('/act', async (req, res) => {
|
|
1130
1330
|
try {
|
|
1131
|
-
const { kind, targetId, userId
|
|
1331
|
+
const { kind, targetId, userId, ...params } = req.body;
|
|
1332
|
+
if (!userId) {
|
|
1333
|
+
return res.status(400).json({ error: 'userId is required' });
|
|
1334
|
+
}
|
|
1132
1335
|
|
|
1133
1336
|
if (!kind) {
|
|
1134
1337
|
return res.status(400).json({ error: 'kind is required' });
|
|
@@ -1252,6 +1455,7 @@ app.post('/act', async (req, res) => {
|
|
|
1252
1455
|
case 'close': {
|
|
1253
1456
|
await tabState.page.close();
|
|
1254
1457
|
found.group.delete(targetId);
|
|
1458
|
+
tabLocks.delete(targetId);
|
|
1255
1459
|
return { ok: true, targetId };
|
|
1256
1460
|
}
|
|
1257
1461
|
|
|
@@ -1262,9 +1466,37 @@ app.post('/act', async (req, res) => {
|
|
|
1262
1466
|
|
|
1263
1467
|
res.json(result);
|
|
1264
1468
|
} catch (err) {
|
|
1265
|
-
|
|
1266
|
-
res.status(500).json({ error: err
|
|
1469
|
+
log('error', 'act failed', { reqId: req.reqId, kind: req.body?.kind, error: err.message });
|
|
1470
|
+
res.status(500).json({ error: safeError(err) });
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
// Periodic stats beacon (every 5 min)
|
|
1475
|
+
setInterval(() => {
|
|
1476
|
+
const mem = process.memoryUsage();
|
|
1477
|
+
let totalTabs = 0;
|
|
1478
|
+
for (const [, session] of sessions) {
|
|
1479
|
+
for (const [, group] of session.tabGroups) {
|
|
1480
|
+
totalTabs += group.size;
|
|
1481
|
+
}
|
|
1267
1482
|
}
|
|
1483
|
+
log('info', 'stats', {
|
|
1484
|
+
sessions: sessions.size,
|
|
1485
|
+
tabs: totalTabs,
|
|
1486
|
+
rssBytes: mem.rss,
|
|
1487
|
+
heapUsedBytes: mem.heapUsed,
|
|
1488
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
1489
|
+
browserConnected: browser?.isConnected() ?? false,
|
|
1490
|
+
});
|
|
1491
|
+
}, 5 * 60_000);
|
|
1492
|
+
|
|
1493
|
+
// Crash logging
|
|
1494
|
+
process.on('uncaughtException', (err) => {
|
|
1495
|
+
log('error', 'uncaughtException', { error: err.message, stack: err.stack });
|
|
1496
|
+
process.exit(1);
|
|
1497
|
+
});
|
|
1498
|
+
process.on('unhandledRejection', (reason) => {
|
|
1499
|
+
log('error', 'unhandledRejection', { reason: String(reason) });
|
|
1268
1500
|
});
|
|
1269
1501
|
|
|
1270
1502
|
// Graceful shutdown
|
|
@@ -1273,10 +1505,10 @@ let shuttingDown = false;
|
|
|
1273
1505
|
async function gracefulShutdown(signal) {
|
|
1274
1506
|
if (shuttingDown) return;
|
|
1275
1507
|
shuttingDown = true;
|
|
1276
|
-
|
|
1508
|
+
log('info', 'shutting down', { signal });
|
|
1277
1509
|
|
|
1278
1510
|
const forceTimeout = setTimeout(() => {
|
|
1279
|
-
|
|
1511
|
+
log('error', 'shutdown timed out, forcing exit');
|
|
1280
1512
|
process.exit(1);
|
|
1281
1513
|
}, 10000);
|
|
1282
1514
|
forceTimeout.unref();
|
|
@@ -1293,19 +1525,19 @@ async function gracefulShutdown(signal) {
|
|
|
1293
1525
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
1294
1526
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1295
1527
|
|
|
1296
|
-
const PORT = process.env.CAMOFOX_PORT || 9377;
|
|
1528
|
+
const PORT = process.env.CAMOFOX_PORT || process.env.PORT || 9377;
|
|
1297
1529
|
const server = app.listen(PORT, () => {
|
|
1298
|
-
|
|
1530
|
+
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
1299
1531
|
ensureBrowser().catch(err => {
|
|
1300
|
-
|
|
1532
|
+
log('error', 'browser pre-launch failed', { error: err.message });
|
|
1301
1533
|
});
|
|
1302
1534
|
});
|
|
1303
1535
|
|
|
1304
1536
|
server.on('error', (err) => {
|
|
1305
1537
|
if (err.code === 'EADDRINUSE') {
|
|
1306
|
-
|
|
1538
|
+
log('error', 'port in use', { port: PORT });
|
|
1307
1539
|
process.exit(1);
|
|
1308
1540
|
}
|
|
1309
|
-
|
|
1541
|
+
log('error', 'server error', { error: err.message });
|
|
1310
1542
|
process.exit(1);
|
|
1311
1543
|
});
|