豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 79f1755

Browse files
committed
chore: implement a daemon process
1 parent 59f6477 commit 79f1755

File tree

4 files changed

+362
-0
lines changed

4 files changed

+362
-0
lines changed

src/daemon/daemon.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* @license
5+
* Copyright 2026 Google LLC
6+
* SPDX-License-Identifier: Apache-2.0
7+
*/
8+
9+
import fs from 'node:fs/promises';
10+
import {createServer, type Server} from 'node:net';
11+
import process from 'node:process';
12+
13+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
14+
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
15+
16+
import {logger} from '../logger.js';
17+
import {PipeTransport} from '../third_party/index.js';
18+
19+
import {
20+
getSocketPath,
21+
handlePidFile,
22+
INDEX_SCRIPT_PATH,
23+
IS_WINDOWS,
24+
} from './utils.js';
25+
26+
const pidFile = handlePidFile();
27+
const socketPath = getSocketPath();
28+
29+
let mcpClient: Client | null = null;
30+
let mcpTransport: StdioClientTransport | null = null;
31+
let server: Server | null = null;
32+
33+
async function setupMCPClient() {
34+
console.log('Setting up MCP client connection...');
35+
36+
const args = process.argv.slice(2);
37+
// Create stdio transport for chrome-devtools-mcp
38+
mcpTransport = new StdioClientTransport({
39+
command: process.execPath,
40+
args: [INDEX_SCRIPT_PATH, ...args],
41+
env: process.env as Record<string, string>,
42+
});
43+
mcpClient = new Client(
44+
{
45+
name: 'chrome-devtools-cli-daemon',
46+
// TODO: handle client version (optional).
47+
version: '0.1.0',
48+
},
49+
{
50+
capabilities: {},
51+
},
52+
);
53+
await mcpClient.connect(mcpTransport);
54+
55+
console.log('MCP client connected');
56+
}
57+
58+
interface McpContent {
59+
type: string;
60+
text?: string;
61+
}
62+
63+
interface McpResult {
64+
content?: McpContent[] | string;
65+
text?: string;
66+
}
67+
68+
type DaemonMessage =
69+
| {
70+
method: 'stop';
71+
}
72+
| {
73+
method: 'invoke_tool';
74+
tool: string;
75+
args?: Record<string, unknown>;
76+
};
77+
78+
async function handleRequest(msg: DaemonMessage) {
79+
try {
80+
if (msg.method === 'invoke_tool') {
81+
if (!mcpClient) {
82+
throw new Error('MCP client not initialized');
83+
}
84+
const {tool, args} = msg;
85+
86+
const result = (await mcpClient.callTool({
87+
name: tool,
88+
arguments: args || {},
89+
})) as McpResult | McpContent[];
90+
91+
return {
92+
success: true,
93+
result: JSON.stringify(result),
94+
};
95+
} else if (msg.method === 'stop') {
96+
// Trigger cleanup asynchronously
97+
setImmediate(() => {
98+
void cleanup();
99+
});
100+
return {
101+
success: true,
102+
message: 'stopping',
103+
};
104+
} else {
105+
return {
106+
success: false,
107+
error: `Unknown method: ${JSON.stringify(msg, null, 2)}`,
108+
};
109+
}
110+
} catch (error: unknown) {
111+
const errorMessage = error instanceof Error ? error.message : String(error);
112+
return {
113+
success: false,
114+
error: errorMessage,
115+
};
116+
}
117+
}
118+
119+
async function startSocketServer() {
120+
// Remove existing socket file if it exists (only on non-Windows)
121+
if (!IS_WINDOWS) {
122+
try {
123+
await fs.unlink(socketPath);
124+
} catch {
125+
// ignore errors.
126+
}
127+
}
128+
129+
return await new Promise<void>((resolve, reject) => {
130+
server = createServer(socket => {
131+
const transport = new PipeTransport(socket, socket);
132+
transport.onmessage = async (message: string) => {
133+
logger('onmessage', message);
134+
const response = await handleRequest(JSON.parse(message));
135+
transport.send(JSON.stringify(response));
136+
socket.end();
137+
};
138+
socket.on('error', error => {
139+
logger('Socket error:', error);
140+
});
141+
});
142+
143+
server.listen(
144+
{
145+
path: socketPath,
146+
readableAll: false,
147+
writableAll: false,
148+
},
149+
async () => {
150+
console.log(`Daemon server listening on ${socketPath}`);
151+
152+
try {
153+
// Setup MCP client
154+
await setupMCPClient();
155+
resolve();
156+
} catch (err) {
157+
reject(err);
158+
}
159+
},
160+
);
161+
162+
server.on('error', error => {
163+
logger('Server error:', error);
164+
reject(error);
165+
});
166+
});
167+
}
168+
169+
async function cleanup() {
170+
console.log('Cleaning up daemon...');
171+
172+
try {
173+
await mcpClient?.close();
174+
} catch (error) {
175+
logger('Error closing MCP client:', error);
176+
}
177+
try {
178+
await mcpTransport?.close();
179+
} catch (error) {
180+
logger('Error closing MCP transport:', error);
181+
}
182+
server?.close(() => {
183+
if (!IS_WINDOWS) {
184+
void fs.unlink(socketPath).catch(() => undefined);
185+
}
186+
});
187+
await fs.unlink(pidFile).catch(() => undefined);
188+
process.exit(0);
189+
}
190+
191+
// Handle shutdown signals
192+
process.on('SIGTERM', () => {
193+
void cleanup();
194+
});
195+
process.on('SIGINT', () => {
196+
void cleanup();
197+
});
198+
process.on('SIGHUP', () => {
199+
void cleanup();
200+
});
201+
202+
// Handle uncaught errors
203+
process.on('uncaughtException', error => {
204+
logger('Uncaught exception:', error);
205+
});
206+
process.on('unhandledRejection', error => {
207+
logger('Unhandled rejection:', error);
208+
});
209+
210+
// Start the server
211+
startSocketServer().catch(error => {
212+
logger('Failed to start daemon server:', error);
213+
process.exit(1);
214+
});

src/daemon/utils.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import fs from 'node:fs';
8+
import os from 'node:os';
9+
import path from 'node:path';
10+
import process from 'node:process';
11+
12+
export const DAEMON_SCRIPT_PATH = path.join(import.meta.dirname, 'daemon.js');
13+
export const INDEX_SCRIPT_PATH = path.join(
14+
import.meta.dirname,
15+
'..',
16+
'index.js',
17+
);
18+
19+
const APP_NAME = 'chrome-devtools-mcp';
20+
21+
// Using these paths due to strict limits on the POSIX socket path length.
22+
export function getSocketPath(): string {
23+
const uid = os.userInfo().uid;
24+
25+
if (IS_WINDOWS) {
26+
// Windows uses Named Pipes, not file paths.
27+
// This format is required for server.listen()
28+
return path.join('\\\\.\\pipe', APP_NAME, 'server.sock');
29+
}
30+
31+
// 1. Try XDG_RUNTIME_DIR (Linux standard, sometimes macOS)
32+
if (process.env.XDG_RUNTIME_DIR) {
33+
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME, 'server.sock');
34+
}
35+
36+
// 2. macOS/Unix Fallback: Use /tmp/
37+
// We use /tmp/ because it is much shorter than ~/Library/Application Support/
38+
// and keeps us well under the 104-character limit.
39+
return path.join('/tmp', `${APP_NAME}-${uid}.sock`);
40+
}
41+
42+
export function getRuntimeHome(): string {
43+
const platform = os.platform();
44+
const uid = os.userInfo().uid;
45+
46+
// 1. Check for the modern Unix standard
47+
if (process.env.XDG_RUNTIME_DIR) {
48+
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME);
49+
}
50+
51+
// 2. Fallback for macOS and older Linux
52+
if (platform === 'darwin' || platform === 'linux') {
53+
// /tmp is cleared on boot, making it perfect for PIDs
54+
return path.join('/tmp', `${APP_NAME}-${uid}`);
55+
}
56+
57+
// 3. Windows Fallback
58+
return path.join(os.tmpdir(), APP_NAME);
59+
}
60+
61+
export const IS_WINDOWS = os.platform() === 'win32';
62+
63+
export function handlePidFile() {
64+
const runtimeDir = getRuntimeHome();
65+
const pidPath = path.join(runtimeDir, 'daemon.pid');
66+
67+
if (fs.existsSync(pidPath)) {
68+
const oldPid = parseInt(fs.readFileSync(pidPath, 'utf8'), 10);
69+
try {
70+
// Sending signal 0 checks if the process is still alive without killing it
71+
process.kill(oldPid, 0);
72+
console.error('Daemon is already running!');
73+
process.exit(1);
74+
} catch {
75+
// Process is dead, we can safely overwrite the PID file
76+
fs.unlinkSync(pidPath);
77+
}
78+
}
79+
80+
fs.mkdirSync(path.dirname(pidPath), {
81+
recursive: true,
82+
});
83+
fs.writeFileSync(pidPath, process.pid.toString());
84+
return pidPath;
85+
}

src/third_party/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export {
3030
} from 'puppeteer-core';
3131
export {default as puppeteer} from 'puppeteer-core';
3232
export type * from 'puppeteer-core';
33+
export {PipeTransport} from 'puppeteer-core/internal/node/PipeTransport.js';
3334
export type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js';
3435
export {
3536
resolveDefaultUserDataDir,

tests/daemon/daemon.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import {spawn} from 'node:child_process';
9+
import net from 'node:net';
10+
import path from 'node:path';
11+
import {describe, it} from 'node:test';
12+
13+
import {getSocketPath} from '../../src/daemon/utils.js';
14+
15+
const DAEMON_SCRIPT = path.join(
16+
import.meta.dirname,
17+
'..',
18+
'..',
19+
'src',
20+
'daemon',
21+
'daemon.js',
22+
);
23+
24+
describe('Daemon', () => {
25+
it('should terminate chrome instance when transport is closed', async () => {
26+
const daemonProcess = spawn(process.execPath, [DAEMON_SCRIPT], {
27+
env: {
28+
...process.env,
29+
},
30+
stdio: ['ignore', 'pipe', 'pipe'],
31+
});
32+
33+
const socketPath = getSocketPath();
34+
// Wait for daemon to be ready
35+
await new Promise<void>((resolve, reject) => {
36+
const onData = (data: Buffer) => {
37+
const output = data.toString();
38+
// Wait for MCP client to connect
39+
if (output.includes('MCP client connected')) {
40+
daemonProcess.stdout.off('data', onData);
41+
resolve();
42+
}
43+
};
44+
daemonProcess.stdout.on('data', onData);
45+
daemonProcess.stderr.on('data', data => {
46+
console.log('err', data.toString('utf8'));
47+
});
48+
daemonProcess.on('error', reject);
49+
daemonProcess.on('exit', (code: number) => {
50+
if (code !== 0 && code !== null) {
51+
reject(new Error(`Daemon exited with code ${code}`));
52+
}
53+
});
54+
});
55+
56+
const socket = net.createConnection(socketPath);
57+
await new Promise<void>(resolve => socket.on('connect', resolve));
58+
59+
daemonProcess.kill();
60+
assert.ok(daemonProcess.killed);
61+
});
62+
});

0 commit comments

Comments
 (0)