-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Expand file tree
/
Copy pathdaemon-client.ts
More file actions
258 lines (235 loc) · 8.99 KB
/
daemon-client.ts
File metadata and controls
258 lines (235 loc) · 8.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
/**
* HTTP client for communicating with the opencli daemon.
*
* Provides a typed send() function that posts a Command and returns a Result.
*/
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { sleep } from '../utils.js';
import { classifyBrowserError } from './errors.js';
import { resolveProfileContextId } from './profile.js';
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };
let _idCounter = 0;
function generateId(): string {
return `cmd_${process.pid}_${Date.now()}_${++_idCounter}`;
}
export interface DaemonCommand {
id: string;
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'paste-files' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
/** Target page identity (targetId). Cross-layer contract with the extension. */
page?: string;
code?: string;
session?: string;
surface?: 'browser' | 'adapter';
/** Adapter site session lifecycle. Persistent site sessions do not idle-expire. */
siteSession?: 'ephemeral' | 'persistent';
url?: string;
op?: string;
index?: number;
domain?: string;
format?: 'png' | 'jpeg';
quality?: number;
fullPage?: boolean;
/** Override viewport width in CSS pixels for screenshot (0 / undefined = use current) */
width?: number;
/** Override viewport height in CSS pixels for screenshot (0 / undefined = use current; ignored when fullPage) */
height?: number;
/** Local file paths for set-file-input action */
files?: string[];
/** CSS selector for file input element (set-file-input action) */
selector?: string;
/** Raw text payload for insert-text action */
text?: string;
/** Base64-encoded files for paste-files action (name, mimeType, base64 content per entry) */
clipboardFiles?: Array<{ name: string; mimeType: string; base64: string }>;
/** URL substring filter pattern for network capture */
pattern?: string;
/** Download wait timeout in milliseconds */
timeoutMs?: number;
cdpMethod?: string;
cdpParams?: Record<string, unknown>;
/** Window foreground/background policy for owned Browser Bridge containers. */
windowMode?: 'foreground' | 'background';
/** Custom idle timeout in seconds for this session. Overrides the default. */
idleTimeout?: number;
/** Frame index for cross-frame operations (0-based, from 'frames' action) */
frameIndex?: number;
/** Browser profile/context to route the command to. */
contextId?: string;
}
export interface DaemonResult {
id: string;
ok: boolean;
data?: unknown;
error?: string;
errorCode?: string;
errorHint?: string;
/** Page identity (targetId) — present on page-scoped command responses */
page?: string;
}
export class BrowserCommandError extends Error {
constructor(message: string, readonly code?: string, readonly hint?: string) {
super(message);
this.name = 'BrowserCommandError';
}
}
export interface DaemonStatus {
ok: boolean;
pid: number;
uptime: number;
daemonVersion?: string;
extensionConnected: boolean;
extensionVersion?: string;
extensionCompatRange?: string;
contextId?: string;
profileRequired?: boolean;
profileDisconnected?: boolean;
profiles?: BrowserProfileStatus[];
pending: number;
commandResultUnknown?: number;
memoryMB: number;
port: number;
}
export interface BrowserProfileStatus {
contextId: string;
extensionConnected: boolean;
extensionVersion?: string;
extensionCompatRange?: string;
pending: number;
lastSeenAt?: number;
}
async function requestDaemon(pathname: string, init?: RequestInit & { timeout?: number }): Promise<Response> {
const { timeout = 2000, headers, ...rest } = init ?? {};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
return await fetch(`${DAEMON_URL}${pathname}`, {
...rest,
headers: { ...OPENCLI_HEADERS, ...headers },
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
export async function fetchDaemonStatus(opts?: { timeout?: number; contextId?: string }): Promise<DaemonStatus | null> {
try {
const params = opts?.contextId ? `?contextId=${encodeURIComponent(opts.contextId)}` : '';
const res = await requestDaemon(`/status${params}`, { timeout: opts?.timeout ?? 2000 });
if (!res.ok) return null;
return await res.json() as DaemonStatus;
} catch {
return null;
}
}
export type DaemonHealth =
| { state: 'stopped'; status: null }
| { state: 'no-extension'; status: DaemonStatus }
| { state: 'profile-required'; status: DaemonStatus }
| { state: 'profile-disconnected'; status: DaemonStatus }
| { state: 'ready'; status: DaemonStatus };
/**
* Unified daemon health check — single entry point for all status queries.
* Replaces isDaemonRunning(), isExtensionConnected(), and checkDaemonStatus().
*/
export async function getDaemonHealth(opts?: { timeout?: number; contextId?: string }): Promise<DaemonHealth> {
const status = await fetchDaemonStatus(opts);
if (!status) return { state: 'stopped', status: null };
if (status.profileRequired) return { state: 'profile-required', status };
if (status.profileDisconnected) return { state: 'profile-disconnected', status };
if (!status.extensionConnected) return { state: 'no-extension', status };
return { state: 'ready', status };
}
export async function requestDaemonShutdown(opts?: { timeout?: number }): Promise<boolean> {
try {
const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
return res.ok;
} catch {
return false;
}
}
/**
* Internal: send a command to the daemon with retry logic.
* Returns the raw DaemonResult. All retry policy lives here — callers
* (sendCommand, sendCommandFull) only shape the return value.
*
* Retries up to 4 times:
* - Network errors (TypeError, AbortError): retry at 500ms
* - Transient browser errors: retry at the delay suggested by classifyBrowserError()
*/
async function sendCommandRaw(
action: DaemonCommand['action'],
params: Omit<DaemonCommand, 'id' | 'action'>,
): Promise<DaemonResult> {
const maxRetries = 4;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const id = generateId();
const rawWindowMode = process.env.OPENCLI_WINDOW;
const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background'
? rawWindowMode
: undefined;
const contextId = params.contextId ?? resolveProfileContextId();
const windowMode = params.windowMode ?? envWindowMode;
const command: DaemonCommand = { id, action, ...params, ...(contextId && { contextId }), ...(windowMode && { windowMode }) };
try {
const res = await requestDaemon('/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
timeout: 30000,
});
const result = (await res.json()) as DaemonResult;
if (!result.ok) {
if (result.errorCode === 'command_result_unknown') {
throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint);
}
const isDuplicateCommandId = res.status === 409
|| (result.error ?? '').includes('Duplicate command id');
if (isDuplicateCommandId && attempt < maxRetries) {
continue;
}
const advice = classifyBrowserError(new Error(result.error ?? ''));
if (advice.retryable && attempt < maxRetries) {
await sleep(advice.delayMs);
continue;
}
throw new BrowserCommandError(result.error ?? 'Daemon command failed', result.errorCode, result.errorHint);
}
return result;
} catch (err) {
const isNetworkError = err instanceof TypeError
|| (err instanceof Error && err.name === 'AbortError');
if (isNetworkError && attempt < maxRetries) {
await sleep(500);
continue;
}
throw err;
}
}
throw new Error('sendCommand: max retries exhausted');
}
/**
* Send a command to the daemon and return the result data.
*/
export async function sendCommand(
action: DaemonCommand['action'],
params: Omit<DaemonCommand, 'id' | 'action'> = {},
): Promise<unknown> {
const result = await sendCommandRaw(action, params);
return result.data;
}
/**
* Like sendCommand, but returns both data and page identity (targetId).
* Use this for page-scoped commands where the caller needs the page identity.
*/
export async function sendCommandFull(
action: DaemonCommand['action'],
params: Omit<DaemonCommand, 'id' | 'action'> = {},
): Promise<{ data: unknown; page?: string }> {
const result = await sendCommandRaw(action, params);
return { data: result.data, page: result.page };
}
export async function bindTab(session: string, opts: { contextId?: string } = {}): Promise<unknown> {
return sendCommand('bind', { session, surface: 'browser', ...opts });
}