@aluvia/sdk 1.1.0 → 1.3.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.
Files changed (77) hide show
  1. package/README.md +409 -285
  2. package/dist/cjs/api/account.js +10 -74
  3. package/dist/cjs/api/apiUtils.js +80 -0
  4. package/dist/cjs/api/geos.js +2 -63
  5. package/dist/cjs/api/request.js +8 -2
  6. package/dist/cjs/bin/account.js +31 -0
  7. package/dist/cjs/bin/api-helpers.js +58 -0
  8. package/dist/cjs/bin/cli.js +245 -0
  9. package/dist/cjs/bin/close.js +120 -0
  10. package/dist/cjs/bin/geos.js +10 -0
  11. package/dist/cjs/bin/mcp-helpers.js +57 -0
  12. package/dist/cjs/bin/mcp-server.js +220 -0
  13. package/dist/cjs/bin/mcp-tools.js +90 -0
  14. package/dist/cjs/bin/open.js +293 -0
  15. package/dist/cjs/bin/session.js +259 -0
  16. package/dist/cjs/client/AluviaClient.js +365 -189
  17. package/dist/cjs/client/BlockDetection.js +486 -0
  18. package/dist/cjs/client/ConfigManager.js +26 -23
  19. package/dist/cjs/client/PageLoadDetection.js +175 -0
  20. package/dist/cjs/client/ProxyServer.js +4 -2
  21. package/dist/cjs/client/logger.js +4 -0
  22. package/dist/cjs/client/rules.js +38 -49
  23. package/dist/cjs/connect.js +117 -0
  24. package/dist/cjs/errors.js +12 -1
  25. package/dist/cjs/index.js +5 -1
  26. package/dist/cjs/session/lock.js +186 -0
  27. package/dist/esm/api/account.js +2 -66
  28. package/dist/esm/api/apiUtils.js +71 -0
  29. package/dist/esm/api/geos.js +2 -63
  30. package/dist/esm/api/request.js +8 -2
  31. package/dist/esm/bin/account.js +28 -0
  32. package/dist/esm/bin/api-helpers.js +53 -0
  33. package/dist/esm/bin/cli.js +242 -0
  34. package/dist/esm/bin/close.js +117 -0
  35. package/dist/esm/bin/geos.js +7 -0
  36. package/dist/esm/bin/mcp-helpers.js +51 -0
  37. package/dist/esm/bin/mcp-server.js +185 -0
  38. package/dist/esm/bin/mcp-tools.js +78 -0
  39. package/dist/esm/bin/open.js +256 -0
  40. package/dist/esm/bin/session.js +252 -0
  41. package/dist/esm/client/AluviaClient.js +371 -195
  42. package/dist/esm/client/BlockDetection.js +482 -0
  43. package/dist/esm/client/ConfigManager.js +21 -18
  44. package/dist/esm/client/PageLoadDetection.js +171 -0
  45. package/dist/esm/client/ProxyServer.js +5 -3
  46. package/dist/esm/client/logger.js +4 -0
  47. package/dist/esm/client/rules.js +36 -49
  48. package/dist/esm/connect.js +81 -0
  49. package/dist/esm/errors.js +10 -0
  50. package/dist/esm/index.js +5 -3
  51. package/dist/esm/session/lock.js +142 -0
  52. package/dist/types/api/AluviaApi.d.ts +2 -7
  53. package/dist/types/api/account.d.ts +1 -16
  54. package/dist/types/api/apiUtils.d.ts +28 -0
  55. package/dist/types/api/geos.d.ts +1 -1
  56. package/dist/types/bin/account.d.ts +1 -0
  57. package/dist/types/bin/api-helpers.d.ts +20 -0
  58. package/dist/types/bin/cli.d.ts +2 -0
  59. package/dist/types/bin/close.d.ts +1 -0
  60. package/dist/types/bin/geos.d.ts +1 -0
  61. package/dist/types/bin/mcp-helpers.d.ts +28 -0
  62. package/dist/types/bin/mcp-server.d.ts +2 -0
  63. package/dist/types/bin/mcp-tools.d.ts +46 -0
  64. package/dist/types/bin/open.d.ts +21 -0
  65. package/dist/types/bin/session.d.ts +11 -0
  66. package/dist/types/client/AluviaClient.d.ts +51 -4
  67. package/dist/types/client/BlockDetection.d.ts +96 -0
  68. package/dist/types/client/ConfigManager.d.ts +6 -1
  69. package/dist/types/client/PageLoadDetection.d.ts +93 -0
  70. package/dist/types/client/logger.d.ts +2 -0
  71. package/dist/types/client/rules.d.ts +18 -0
  72. package/dist/types/client/types.d.ts +48 -47
  73. package/dist/types/connect.d.ts +18 -0
  74. package/dist/types/errors.d.ts +6 -0
  75. package/dist/types/index.d.ts +7 -5
  76. package/dist/types/session/lock.d.ts +43 -0
  77. package/package.json +11 -10
@@ -0,0 +1,7 @@
1
+ import { requireApi } from './api-helpers.js';
2
+ import { output } from './cli.js';
3
+ export async function handleGeos() {
4
+ const api = requireApi();
5
+ const geos = await api.geos.list();
6
+ return output({ geos, count: geos.length });
7
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * MCP output capture helpers.
3
+ *
4
+ * The CLI handlers call `output()` which normally does `console.log` + `process.exit()`.
5
+ * In MCP mode, we switch `output()` to throw an MCPOutputCapture instead,
6
+ * allowing us to catch and return the data without exiting the process.
7
+ *
8
+ * Uses AsyncLocalStorage so concurrent MCP tool calls don't interfere.
9
+ */
10
+ import { AsyncLocalStorage } from "node:async_hooks";
11
+ /**
12
+ * Thrown by output() when in capture mode.
13
+ * Contains the JSON data and exit code that would have been written to stdout.
14
+ */
15
+ export class MCPOutputCapture {
16
+ constructor(data, exitCode) {
17
+ this.data = data;
18
+ this.exitCode = exitCode;
19
+ }
20
+ }
21
+ const captureContext = new AsyncLocalStorage();
22
+ export function isCapturing() {
23
+ return captureContext.getStore() ?? false;
24
+ }
25
+ /**
26
+ * Run a CLI handler function in capture mode.
27
+ * Returns the data that output() would have written to stdout.
28
+ * Safe for concurrent use — each call gets its own async context.
29
+ */
30
+ export async function captureOutput(fn) {
31
+ return captureContext.run(true, async () => {
32
+ try {
33
+ await fn();
34
+ // Handler completed without calling output() — shouldn't happen for CLI handlers
35
+ return { data: { error: "Handler did not produce output" }, isError: true };
36
+ }
37
+ catch (err) {
38
+ if (err instanceof MCPOutputCapture) {
39
+ return {
40
+ data: err.data,
41
+ isError: err.exitCode !== 0,
42
+ };
43
+ }
44
+ // Unexpected error (not from output())
45
+ return {
46
+ data: { error: err instanceof Error ? err.message : String(err) },
47
+ isError: true,
48
+ };
49
+ }
50
+ });
51
+ }
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import * as tools from "./mcp-tools.js";
6
+ const server = new McpServer({
7
+ name: "aluvia",
8
+ version: "1.2.0",
9
+ });
10
+ // --- Session tools ---
11
+ server.tool("session_start", "Start a browser session with Aluvia smart proxy. Spawns a headless browser connected through Aluvia gateway. Returns session details including CDP URL for remote debugging.", {
12
+ url: z.string().describe("URL to open in the browser"),
13
+ connectionId: z
14
+ .number()
15
+ .int()
16
+ .positive()
17
+ .optional()
18
+ .describe("Use a specific Aluvia connection ID"),
19
+ headful: z
20
+ .boolean()
21
+ .optional()
22
+ .describe("Run browser in headful mode (default: headless)"),
23
+ browserSession: z
24
+ .string()
25
+ .optional()
26
+ .describe("Custom session name (auto-generated if omitted)"),
27
+ autoUnblock: z
28
+ .boolean()
29
+ .optional()
30
+ .describe("Auto-detect blocks and reload through Aluvia proxy"),
31
+ disableBlockDetection: z
32
+ .boolean()
33
+ .optional()
34
+ .describe("Disable block detection entirely"),
35
+ }, async (args) => {
36
+ const result = await tools.sessionStart(args);
37
+ return {
38
+ content: [
39
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
40
+ ],
41
+ isError: result.isError,
42
+ };
43
+ });
44
+ server.tool("session_close", "Close one or all running browser sessions. Sends SIGTERM for graceful shutdown, then SIGKILL if needed.", {
45
+ browserSession: z
46
+ .string()
47
+ .optional()
48
+ .describe("Name of session to close (auto-selects if only one)"),
49
+ all: z.boolean().optional().describe("Close all sessions"),
50
+ }, async (args) => {
51
+ const result = await tools.sessionClose(args);
52
+ return {
53
+ content: [
54
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
55
+ ],
56
+ isError: result.isError,
57
+ };
58
+ });
59
+ server.tool("session_list", "List all active browser sessions with their PIDs, URLs, and proxy configuration.", async () => {
60
+ const result = await tools.sessionList();
61
+ return {
62
+ content: [
63
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
64
+ ],
65
+ isError: result.isError,
66
+ };
67
+ });
68
+ server.tool("session_get", "Get detailed information about a running session including proxy URLs, connection data, and block detection state.", {
69
+ browserSession: z
70
+ .string()
71
+ .optional()
72
+ .describe("Name of session (auto-selects if only one)"),
73
+ }, async (args) => {
74
+ const result = await tools.sessionGet(args);
75
+ return {
76
+ content: [
77
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
78
+ ],
79
+ isError: result.isError,
80
+ };
81
+ });
82
+ server.tool("session_rotate_ip", "Rotate the IP address for a running session by generating a new session ID on the Aluvia connection.", {
83
+ browserSession: z
84
+ .string()
85
+ .optional()
86
+ .describe("Name of session (auto-selects if only one)"),
87
+ }, async (args) => {
88
+ const result = await tools.sessionRotateIp(args);
89
+ return {
90
+ content: [
91
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
92
+ ],
93
+ isError: result.isError,
94
+ };
95
+ });
96
+ server.tool("session_set_geo", "Set or clear the target geographic region for a running session. Affects which mobile IP pool is used.", {
97
+ geo: z.string().optional().describe('Geo code to set (e.g. "US", "GB")'),
98
+ clear: z
99
+ .boolean()
100
+ .optional()
101
+ .describe("Clear the target geo instead of setting one"),
102
+ browserSession: z
103
+ .string()
104
+ .optional()
105
+ .describe("Name of session (auto-selects if only one)"),
106
+ }, async (args) => {
107
+ const result = await tools.sessionSetGeo(args);
108
+ return {
109
+ content: [
110
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
111
+ ],
112
+ isError: result.isError,
113
+ };
114
+ });
115
+ server.tool("session_set_rules", 'Append or remove proxy routing rules for a running session. Rules are hostname patterns (e.g. "example.com", "*.google.com").', {
116
+ rules: z
117
+ .string()
118
+ .optional()
119
+ .describe('Comma-separated rules to append (e.g. "a.com,b.com")'),
120
+ remove: z
121
+ .string()
122
+ .optional()
123
+ .describe("Comma-separated rules to remove instead of appending"),
124
+ browserSession: z
125
+ .string()
126
+ .optional()
127
+ .describe("Name of session (auto-selects if only one)"),
128
+ }, async (args) => {
129
+ const result = await tools.sessionSetRules(args);
130
+ return {
131
+ content: [
132
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
133
+ ],
134
+ isError: result.isError,
135
+ };
136
+ });
137
+ // --- Account tools ---
138
+ server.tool("account_get", "Get Aluvia account information including plan details and current balance.", async () => {
139
+ const result = await tools.accountGet();
140
+ return {
141
+ content: [
142
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
143
+ ],
144
+ isError: result.isError,
145
+ };
146
+ });
147
+ server.tool("account_usage", "Get Aluvia account usage statistics for a date range.", {
148
+ start: z
149
+ .string()
150
+ .optional()
151
+ .describe('Start date filter (ISO8601 format, e.g. "2024-01-01T00:00:00Z")'),
152
+ end: z.string().optional().describe("End date filter (ISO8601 format)"),
153
+ }, async (args) => {
154
+ const result = await tools.accountUsage(args);
155
+ return {
156
+ content: [
157
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
158
+ ],
159
+ isError: result.isError,
160
+ };
161
+ });
162
+ // --- Geo tools ---
163
+ server.tool("geos_list", "List all available geographic regions for proxy targeting.", async () => {
164
+ const result = await tools.geosList();
165
+ return {
166
+ content: [
167
+ { type: "text", text: JSON.stringify(result.data, null, 2) },
168
+ ],
169
+ isError: result.isError,
170
+ };
171
+ });
172
+ // --- Start server ---
173
+ async function main() {
174
+ const transport = new StdioServerTransport();
175
+ await server.connect(transport);
176
+ console.error("Aluvia MCP server running on stdio");
177
+ }
178
+ // Only run when executed directly (not when imported for testing)
179
+ const isMcpServer = process.argv[1]?.match(/(?:mcp-server)\.[jt]s$/);
180
+ if (isMcpServer) {
181
+ main().catch((err) => {
182
+ console.error("Fatal error in MCP server:", err);
183
+ process.exit(1);
184
+ });
185
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * MCP tool implementations.
3
+ *
4
+ * Each tool wraps the corresponding CLI handler via captureOutput(),
5
+ * converting the handler's JSON output into MCP tool results.
6
+ */
7
+ import { handleSession } from "./session.js";
8
+ import { handleAccount } from "./account.js";
9
+ import { handleGeos } from "./geos.js";
10
+ import { handleOpen } from "./open.js";
11
+ import { captureOutput } from "./mcp-helpers.js";
12
+ export async function sessionStart(args) {
13
+ return captureOutput(() => handleOpen({
14
+ url: args.url,
15
+ connectionId: args.connectionId,
16
+ headless: !args.headful,
17
+ sessionName: args.browserSession,
18
+ autoUnblock: args.autoUnblock,
19
+ disableBlockDetection: args.disableBlockDetection,
20
+ }));
21
+ }
22
+ export async function sessionClose(args) {
23
+ const cliArgs = ["close"];
24
+ if (args.browserSession)
25
+ cliArgs.push("--browser-session", args.browserSession);
26
+ if (args.all)
27
+ cliArgs.push("--all");
28
+ return captureOutput(() => handleSession(cliArgs));
29
+ }
30
+ export async function sessionList() {
31
+ return captureOutput(() => handleSession(["list"]));
32
+ }
33
+ export async function sessionGet(args) {
34
+ const cliArgs = ["get"];
35
+ if (args.browserSession)
36
+ cliArgs.push("--browser-session", args.browserSession);
37
+ return captureOutput(() => handleSession(cliArgs));
38
+ }
39
+ export async function sessionRotateIp(args) {
40
+ const cliArgs = ["rotate-ip"];
41
+ if (args.browserSession)
42
+ cliArgs.push("--browser-session", args.browserSession);
43
+ return captureOutput(() => handleSession(cliArgs));
44
+ }
45
+ export async function sessionSetGeo(args) {
46
+ const cliArgs = ["set-geo"];
47
+ if (args.geo)
48
+ cliArgs.push(args.geo);
49
+ if (args.clear)
50
+ cliArgs.push("--clear");
51
+ if (args.browserSession)
52
+ cliArgs.push("--browser-session", args.browserSession);
53
+ return captureOutput(() => handleSession(cliArgs));
54
+ }
55
+ export async function sessionSetRules(args) {
56
+ const cliArgs = ["set-rules"];
57
+ if (args.rules)
58
+ cliArgs.push(args.rules);
59
+ if (args.remove)
60
+ cliArgs.push("--remove", args.remove);
61
+ if (args.browserSession)
62
+ cliArgs.push("--browser-session", args.browserSession);
63
+ return captureOutput(() => handleSession(cliArgs));
64
+ }
65
+ export async function accountGet() {
66
+ return captureOutput(() => handleAccount([]));
67
+ }
68
+ export async function accountUsage(args) {
69
+ const cliArgs = ["usage"];
70
+ if (args.start)
71
+ cliArgs.push("--start", args.start);
72
+ if (args.end)
73
+ cliArgs.push("--end", args.end);
74
+ return captureOutput(() => handleAccount(cliArgs));
75
+ }
76
+ export async function geosList() {
77
+ return captureOutput(() => handleGeos());
78
+ }
@@ -0,0 +1,256 @@
1
+ import { AluviaClient } from '../client/AluviaClient.js';
2
+ import { writeLock, readLock, removeLock, isProcessAlive, getLogFilePath, generateSessionName, validateSessionName } from '../session/lock.js';
3
+ import { output } from './cli.js';
4
+ import { spawn } from 'node:child_process';
5
+ import * as fs from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ /**
9
+ * Called from cli.ts when running `session start <url>`.
10
+ * Spawns the actual browser in a detached child and polls until ready.
11
+ * Returns a Promise that resolves via process.exit() (never returns normally).
12
+ */
13
+ export function handleOpen({ url, connectionId, headless, sessionName, autoUnblock, disableBlockDetection, run }) {
14
+ // Generate session name if not provided
15
+ const session = sessionName ?? generateSessionName();
16
+ // Validate session name early (before spawning daemon)
17
+ if (sessionName && !validateSessionName(sessionName)) {
18
+ output({ error: 'Invalid session name. Use only letters, numbers, hyphens, and underscores.' }, 1);
19
+ }
20
+ // Check for existing instance with this session name
21
+ const existing = readLock(session);
22
+ if (existing !== null && isProcessAlive(existing.pid)) {
23
+ output({
24
+ error: `A browser session named '${session}' is already running.`,
25
+ browserSession: session,
26
+ startUrl: existing.url ?? null,
27
+ cdpUrl: existing.cdpUrl ?? null,
28
+ connectionId: existing.connectionId ?? null,
29
+ pid: existing.pid,
30
+ }, 1);
31
+ }
32
+ // Clean up stale lock if process is dead
33
+ if (existing !== null) {
34
+ removeLock(session);
35
+ }
36
+ // Require API key
37
+ const apiKey = process.env.ALUVIA_API_KEY;
38
+ if (!apiKey) {
39
+ output({ error: 'ALUVIA_API_KEY environment variable is required.' }, 1);
40
+ }
41
+ // Spawn a detached child process that runs the daemon
42
+ const logFile = getLogFilePath(session);
43
+ const out = fs.openSync(logFile, 'a');
44
+ const args = ['--daemon', url, '--browser-session', session];
45
+ if (connectionId != null) {
46
+ args.push('--connection-id', String(connectionId));
47
+ }
48
+ if (!headless) {
49
+ args.push('--headful');
50
+ }
51
+ if (autoUnblock) {
52
+ args.push('--auto-unblock');
53
+ }
54
+ if (disableBlockDetection) {
55
+ args.push('--disable-block-detection');
56
+ }
57
+ if (run) {
58
+ args.push('--run', run);
59
+ }
60
+ let child;
61
+ try {
62
+ child = spawn(process.execPath, [process.argv[1], ...args], {
63
+ detached: true,
64
+ stdio: ['ignore', out, out],
65
+ env: { ...process.env, ALUVIA_API_KEY: apiKey },
66
+ });
67
+ child.unref();
68
+ }
69
+ catch (err) {
70
+ fs.closeSync(out);
71
+ return output({ browserSession: session, error: `Failed to spawn browser process: ${err.message}` }, 1);
72
+ }
73
+ fs.closeSync(out);
74
+ // Wait for the daemon to be fully ready (lock file with ready: true)
75
+ return new Promise(() => {
76
+ let attempts = 0;
77
+ const maxAttempts = 240; // 60 seconds max
78
+ const poll = setInterval(() => {
79
+ attempts++;
80
+ // Early exit if daemon process died
81
+ if (child.pid && !isProcessAlive(child.pid)) {
82
+ clearInterval(poll);
83
+ removeLock(session);
84
+ output({
85
+ browserSession: session,
86
+ error: 'Browser process exited unexpectedly.',
87
+ logFile,
88
+ }, 1);
89
+ }
90
+ const lock = readLock(session);
91
+ if (lock && lock.ready) {
92
+ clearInterval(poll);
93
+ output({
94
+ browserSession: session,
95
+ pid: lock.pid,
96
+ startUrl: lock.url ?? null,
97
+ cdpUrl: lock.cdpUrl ?? null,
98
+ connectionId: lock.connectionId ?? null,
99
+ blockDetection: lock.blockDetection ?? false,
100
+ autoUnblock: lock.autoUnblock ?? false,
101
+ });
102
+ }
103
+ if (attempts >= maxAttempts) {
104
+ clearInterval(poll);
105
+ const alive = child.pid ? isProcessAlive(child.pid) : false;
106
+ output({
107
+ browserSession: session,
108
+ error: alive ? 'Browser is still initializing (timeout).' : 'Browser process exited unexpectedly.',
109
+ logFile,
110
+ }, 1);
111
+ }
112
+ }, 250);
113
+ });
114
+ }
115
+ /**
116
+ * Daemon entry point — runs in the detached child process.
117
+ * Starts the proxy + browser, writes lock, and stays alive.
118
+ * Logs go to the daemon log file (stdout is redirected), not to the user.
119
+ */
120
+ export async function handleOpenDaemon({ url, connectionId, headless, sessionName, autoUnblock, disableBlockDetection, run }) {
121
+ const apiKey = process.env.ALUVIA_API_KEY;
122
+ const blockDetectionEnabled = !disableBlockDetection;
123
+ const updateLockWithDetection = (result) => {
124
+ const lock = readLock(sessionName);
125
+ if (!lock)
126
+ return;
127
+ const lastDetection = {
128
+ hostname: result.hostname,
129
+ lastUrl: result.url,
130
+ blockStatus: result.blockStatus,
131
+ score: result.score,
132
+ signals: result.signals.map((s) => s.name),
133
+ pass: result.pass,
134
+ persistentBlock: result.persistentBlock,
135
+ timestamp: Date.now(),
136
+ };
137
+ writeLock({ ...lock, lastDetection }, sessionName);
138
+ };
139
+ const client = new AluviaClient({
140
+ apiKey,
141
+ startPlaywright: true,
142
+ ...(connectionId != null ? { connectionId } : {}),
143
+ headless: headless ?? true,
144
+ blockDetection: blockDetectionEnabled
145
+ ? autoUnblock
146
+ ? { enabled: true, autoUnblock: true, onDetection: updateLockWithDetection }
147
+ : { enabled: true, onDetection: updateLockWithDetection }
148
+ : { enabled: false },
149
+ });
150
+ const connection = await client.start();
151
+ // Write early lock so parent knows daemon is alive
152
+ writeLock({ pid: process.pid, session: sessionName, url, proxyUrl: connection.url, blockDetection: blockDetectionEnabled, autoUnblock: blockDetectionEnabled && !!autoUnblock }, sessionName);
153
+ if (autoUnblock)
154
+ console.log('[daemon] Auto-unblock enabled');
155
+ console.log(`[daemon] Browser initialized — proxy: ${connection.url}`);
156
+ if (connection.cdpUrl)
157
+ console.log(`[daemon] CDP URL: ${connection.cdpUrl}`);
158
+ if (connectionId != null)
159
+ console.log(`[daemon] Connection ID: ${connectionId}`);
160
+ if (sessionName)
161
+ console.log(`[daemon] Session: ${sessionName}`);
162
+ console.log(`[daemon] Opening ${url}`);
163
+ // Navigate to URL in the browser
164
+ const page = await connection.browserContext.newPage();
165
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
166
+ // Gather session info
167
+ const pageTitle = await page.title().catch(() => '');
168
+ const cdpUrl = connection.cdpUrl ?? '';
169
+ // Get connection ID: use the one passed in, or read from ConfigManager
170
+ const connId = connectionId ?? client.connectionId;
171
+ // Write lock file with full session metadata (marks session as ready)
172
+ // Read existing lock first to preserve lastDetection written by the onDetection callback
173
+ const existingLock = readLock(sessionName);
174
+ writeLock({
175
+ pid: process.pid,
176
+ session: sessionName,
177
+ connectionId: connId,
178
+ cdpUrl,
179
+ proxyUrl: connection.url,
180
+ url,
181
+ ready: true,
182
+ blockDetection: blockDetectionEnabled,
183
+ autoUnblock: blockDetectionEnabled && !!autoUnblock,
184
+ lastDetection: existingLock?.lastDetection,
185
+ }, sessionName);
186
+ console.log(`[daemon] Session ready — session: ${sessionName ?? 'default'}, url: ${url}, cdpUrl: ${cdpUrl}, connectionId: ${connId ?? 'unknown'}, pid: ${process.pid}`);
187
+ if (pageTitle)
188
+ console.log(`[daemon] Page title: ${pageTitle}`);
189
+ // If --run was provided, execute the script and then shut down
190
+ if (run) {
191
+ const scriptPath = path.resolve(run);
192
+ if (!fs.existsSync(scriptPath)) {
193
+ console.error(`[daemon] Script not found: ${scriptPath}`);
194
+ removeLock(sessionName);
195
+ await connection.close();
196
+ process.exit(1);
197
+ }
198
+ console.log(`[daemon] Running script: ${scriptPath}`);
199
+ // Inject page, browser, context as globals so the script can use them directly
200
+ const browser = connection.browser;
201
+ const context = connection.browserContext;
202
+ globalThis.page = page;
203
+ globalThis.browser = browser;
204
+ globalThis.context = context;
205
+ let exitCode = 0;
206
+ try {
207
+ await import(pathToFileURL(scriptPath).href);
208
+ }
209
+ catch (err) {
210
+ console.error(`[daemon] Script error: ${err.message}`);
211
+ if (err.stack)
212
+ console.error(err.stack);
213
+ exitCode = 1;
214
+ }
215
+ // Clean up globals
216
+ delete globalThis.page;
217
+ delete globalThis.browser;
218
+ delete globalThis.context;
219
+ console.log(`[daemon] Script finished.`);
220
+ removeLock(sessionName);
221
+ await connection.close();
222
+ process.exit(exitCode);
223
+ }
224
+ // Graceful shutdown handler
225
+ let stopping = false;
226
+ const shutdown = async () => {
227
+ if (stopping)
228
+ return;
229
+ stopping = true;
230
+ console.log('[daemon] Shutting down...');
231
+ try {
232
+ await connection.close();
233
+ }
234
+ catch {
235
+ // ignore
236
+ }
237
+ removeLock(sessionName);
238
+ console.log('[daemon] Stopped.');
239
+ process.exit(0);
240
+ };
241
+ process.on('SIGINT', shutdown);
242
+ process.on('SIGTERM', shutdown);
243
+ // Detect when the browser is closed by the user
244
+ if (connection.browser) {
245
+ connection.browser.on('disconnected', () => {
246
+ if (!stopping) {
247
+ console.log('[daemon] Browser closed by user.');
248
+ removeLock(sessionName);
249
+ client
250
+ .stop()
251
+ .catch(() => { })
252
+ .finally(() => process.exit(0));
253
+ }
254
+ });
255
+ }
256
+ }