@mearl/client 2.5.1 → 2.6.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.
@@ -0,0 +1,562 @@
1
+ import WebSocket from 'ws';
2
+ import { DEFAULT_CONNECT_TIMEOUT, DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_REQUEST_TIMEOUT, MAX_BUFFER_SIZE, isHeartbeatMessage, resolveCloudConnectorSelector, } from '@mearl/cloud-types';
3
+ import { resolveActionTimeoutSec } from './actionTimeouts.js';
4
+ import { invoke as invokeLocalBrowser } from './socket.js';
5
+ import { loadCloudServerConfig, loadLocalConnectorId } from './unifiedConfig.js';
6
+ const RESPONSE_GRACE_MS = 1_000;
7
+ const GLOBAL_BROWSER_PREFIX = 'mearl:';
8
+ function encodeBrowserTarget(target) {
9
+ const browser = encodeURIComponent(target.browser);
10
+ return target.connector
11
+ ? `${GLOBAL_BROWSER_PREFIX}remote:${encodeURIComponent(target.connector)}:${browser}`
12
+ : `${GLOBAL_BROWSER_PREFIX}local:${browser}`;
13
+ }
14
+ function omitTransientBrowserSecrets(browser) {
15
+ const safeBrowser = { ...browser };
16
+ delete safeBrowser.agentbayAccessUrl;
17
+ delete safeBrowser.accessUrl;
18
+ return safeBrowser;
19
+ }
20
+ export function decodeBrowserTarget(selector) {
21
+ if (!selector?.startsWith(GLOBAL_BROWSER_PREFIX))
22
+ return undefined;
23
+ try {
24
+ const parts = selector.slice(GLOBAL_BROWSER_PREFIX.length).split(':');
25
+ if (parts[0] === 'local' && parts.length === 2) {
26
+ const browser = decodeURIComponent(parts[1]);
27
+ return browser ? { browser } : undefined;
28
+ }
29
+ if (parts[0] === 'remote' && parts.length === 3) {
30
+ const connector = decodeURIComponent(parts[1]);
31
+ const browser = decodeURIComponent(parts[2]);
32
+ return connector && browser ? { connector, browser } : undefined;
33
+ }
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ return undefined;
39
+ }
40
+ function decorateBrowser(browser, source) {
41
+ const safeBrowser = omitTransientBrowserSecrets(browser);
42
+ const originalBrowserId = String(browser.browserId);
43
+ const originalName = String(browser.name);
44
+ return {
45
+ ...safeBrowser,
46
+ browserId: encodeBrowserTarget({
47
+ browser: originalBrowserId,
48
+ ...(source.connectorId ? { connector: source.connectorId } : {}),
49
+ }),
50
+ name: source.kind === 'local'
51
+ ? `[this machine] ${originalName}`
52
+ : `[${source.connectorName}] ${originalName}`,
53
+ originalBrowserId,
54
+ originalName,
55
+ status: typeof browser.status === 'string' ? browser.status : 'disconnected',
56
+ source,
57
+ };
58
+ }
59
+ function browserMatches(browser, selector) {
60
+ const normalized = selector.trim().toLowerCase();
61
+ return (browser.browserId.toLowerCase() === normalized ||
62
+ browser.originalBrowserId?.toLowerCase() === normalized ||
63
+ browser.name.toLowerCase() === normalized ||
64
+ browser.originalName?.toLowerCase() === normalized);
65
+ }
66
+ function findBrowsers(browsers, selector, connectedOnly = true) {
67
+ const candidates = connectedOnly
68
+ ? browsers.filter(browser => browser.status === 'connected')
69
+ : browsers;
70
+ const exact = candidates.filter(browser => browserMatches(browser, selector));
71
+ if (exact.length > 0)
72
+ return exact;
73
+ const normalized = selector.trim().toLowerCase();
74
+ return candidates.filter(browser => browser.browserId.toLowerCase().includes(normalized) ||
75
+ browser.originalBrowserId?.toLowerCase().includes(normalized) ||
76
+ browser.name.toLowerCase().includes(normalized) ||
77
+ browser.originalName?.toLowerCase().includes(normalized));
78
+ }
79
+ function errorMessage(error) {
80
+ return error instanceof Error ? error.message : String(error);
81
+ }
82
+ export class UnifiedClient {
83
+ options;
84
+ defaultBrowser;
85
+ defaultConnector;
86
+ ws = null;
87
+ connectPromise = null;
88
+ messageBuffer = '';
89
+ pendingRequests = new Map();
90
+ heartbeatTimer = null;
91
+ closing = false;
92
+ constructor(options = {}) {
93
+ this.options = {
94
+ ...(options.serverUrl ? { serverUrl: options.serverUrl } : {}),
95
+ connectTimeoutSec: options.connectTimeoutSec ?? DEFAULT_CONNECT_TIMEOUT,
96
+ requestTimeoutSec: options.requestTimeoutSec ?? DEFAULT_REQUEST_TIMEOUT,
97
+ heartbeatInterval: options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL,
98
+ };
99
+ this.defaultBrowser =
100
+ options.defaultBrowser ?? (process.env.MEARL_BROWSER?.trim() || undefined);
101
+ this.defaultConnector = options.defaultConnector;
102
+ }
103
+ disconnect() {
104
+ this.closing = true;
105
+ if (this.heartbeatTimer)
106
+ clearInterval(this.heartbeatTimer);
107
+ this.heartbeatTimer = null;
108
+ this.rejectAllPending(new Error('Connection closed by user'));
109
+ this.ws?.close();
110
+ this.ws = null;
111
+ }
112
+ async invoke(action, data = {}, options = {}) {
113
+ const timeoutSec = options.timeoutSec ?? resolveActionTimeoutSec(action, data, this.options.requestTimeoutSec);
114
+ if (action === 'connector_list')
115
+ return this.listConnectors(timeoutSec);
116
+ if (action === 'browser_list') {
117
+ if (options.local || options.transport === 'cdp') {
118
+ return this.listLocalBrowsers(timeoutSec, options.transport);
119
+ }
120
+ const connector = options.connector ?? this.defaultConnector;
121
+ return connector
122
+ ? this.listConnectorBrowsers(connector, timeoutSec)
123
+ : this.listAllBrowsers(timeoutSec);
124
+ }
125
+ if (action === 'browser_select_browser') {
126
+ return this.selectDefaultBrowser(data, options, timeoutSec);
127
+ }
128
+ if (options.local || options.transport === 'cdp') {
129
+ return this.invokeExplicitLocal(action, data, options, timeoutSec);
130
+ }
131
+ const optionTarget = decodeBrowserTarget(options.browser);
132
+ const defaultTarget = options.browser ? undefined : decodeBrowserTarget(this.defaultBrowser);
133
+ const closeTarget = action === 'browser_close' && typeof data.browser === 'string'
134
+ ? decodeBrowserTarget(data.browser)
135
+ : undefined;
136
+ const target = optionTarget ?? closeTarget ?? defaultTarget;
137
+ const connector = target?.connector ?? options.connector ?? this.defaultConnector;
138
+ const browser = optionTarget?.browser ??
139
+ defaultTarget?.browser ??
140
+ (options.browser && !optionTarget ? options.browser : undefined) ??
141
+ (this.defaultBrowser && !defaultTarget ? this.defaultBrowser : undefined);
142
+ const requestData = closeTarget ? { ...data, browser: closeTarget.browser } : data;
143
+ if (target && !target.connector) {
144
+ if (options.connector) {
145
+ throw new Error('A local browser cannot be used together with --connector.');
146
+ }
147
+ return this.invokeLocal(action, requestData, timeoutSec, target.browser);
148
+ }
149
+ if (connector) {
150
+ const selected = target?.connector && !options.connector && !this.defaultConnector
151
+ ? { connectorId: target.connector, name: target.connector }
152
+ : await this.resolveConnector(connector, timeoutSec);
153
+ if (target?.connector && selected.connectorId !== target.connector) {
154
+ throw new Error('The selected browser belongs to a different cloud connector.');
155
+ }
156
+ return this.invokeRemote(action, requestData, timeoutSec, browser, selected);
157
+ }
158
+ if (!this.options.serverUrl) {
159
+ return this.invokeLocal(action, requestData, timeoutSec, options.browser ?? this.defaultBrowser);
160
+ }
161
+ const selector = options.browser ??
162
+ this.defaultBrowser ??
163
+ (action === 'browser_close' && typeof data.browser === 'string' ? data.browser : undefined);
164
+ const resolved = await this.resolveUnifiedBrowser(selector, timeoutSec, action === 'browser_close');
165
+ if (resolved) {
166
+ if (action === 'browser_close') {
167
+ const nextOptions = { ...options };
168
+ delete nextOptions.browser;
169
+ return this.invoke(action, { ...data, browser: resolved }, nextOptions);
170
+ }
171
+ return this.invoke(action, data, { ...options, browser: resolved });
172
+ }
173
+ if (action === 'browser_launch') {
174
+ return this.invokeLaunchWithoutControlBrowser(data, timeoutSec);
175
+ }
176
+ throw new Error('No connected browser is available. Run `mearl browser_list` to inspect all sources.');
177
+ }
178
+ async invokeExplicitLocal(action, data, options, timeoutSec) {
179
+ const optionTarget = decodeBrowserTarget(options.browser);
180
+ const defaultTarget = options.browser ? undefined : decodeBrowserTarget(this.defaultBrowser);
181
+ const closeTarget = action === 'browser_close' && typeof data.browser === 'string'
182
+ ? decodeBrowserTarget(data.browser)
183
+ : undefined;
184
+ if (optionTarget?.connector || defaultTarget?.connector || closeTarget?.connector) {
185
+ throw new Error('A remote browser cannot be used together with --local or --cdp.');
186
+ }
187
+ const browser = optionTarget?.browser ?? defaultTarget?.browser ?? options.browser ?? this.defaultBrowser;
188
+ const requestData = closeTarget ? { ...data, browser: closeTarget.browser } : data;
189
+ return this.invokeLocal(action, requestData, timeoutSec, browser, options.transport);
190
+ }
191
+ async invokeLaunchWithoutControlBrowser(data, timeoutSec) {
192
+ const local = await this.tryLocalBrowserList(timeoutSec);
193
+ const discoveredConnectors = await this.listConnectors(timeoutSec).then(result => result.connectors);
194
+ const localAvailable = local !== null;
195
+ const localConnectorId = localAvailable ? loadLocalConnectorId() : null;
196
+ const connectors = localConnectorId
197
+ ? discoveredConnectors.filter(connector => connector.connectorId !== localConnectorId)
198
+ : discoveredConnectors;
199
+ if (localAvailable && connectors.length === 0) {
200
+ return this.invokeLocal('browser_launch', data, timeoutSec, undefined);
201
+ }
202
+ if (!localAvailable && connectors.length === 1) {
203
+ return this.invokeRemote('browser_launch', data, timeoutSec, undefined, connectors[0]);
204
+ }
205
+ if (!localAvailable && connectors.length === 0) {
206
+ throw new Error('No browser host is available. Install @mearl/native-host locally or connect a cloud connector.');
207
+ }
208
+ throw new Error('The browser host for browser_launch is ambiguous. Pass --local, --connector <id|name>, or --browser <global-id>.');
209
+ }
210
+ async selectDefaultBrowser(data, options, timeoutSec) {
211
+ const selector = typeof data.browser === 'string' ? data.browser.trim() : '';
212
+ if (!selector) {
213
+ this.defaultBrowser = undefined;
214
+ this.defaultConnector = undefined;
215
+ return { selected: null, note: 'Cleared the process-wide default browser.' };
216
+ }
217
+ const listing = (await this.invoke('browser_list', {}, {
218
+ timeoutSec,
219
+ ...(options.local ? { local: true } : {}),
220
+ ...(options.transport ? { transport: options.transport } : {}),
221
+ ...(options.connector ? { connector: options.connector } : {}),
222
+ }));
223
+ const matches = findBrowsers(listing.browsers, selector);
224
+ if (matches.length > 1) {
225
+ throw new Error(`Browser selector "${selector}" is ambiguous. Use an exact global browserId.`);
226
+ }
227
+ const match = matches[0];
228
+ if (!match)
229
+ throw new Error(`No connected browser matches "${selector}".`);
230
+ this.defaultBrowser = match.browserId;
231
+ this.defaultConnector = undefined;
232
+ return { selected: { browserId: match.browserId, name: match.name } };
233
+ }
234
+ async resolveUnifiedBrowser(selector, timeoutSec, includeDisconnected) {
235
+ const listing = await this.listAllBrowsers(timeoutSec);
236
+ if (selector) {
237
+ const matches = findBrowsers(listing.browsers, selector, !includeDisconnected);
238
+ if (matches.length > 1) {
239
+ throw new Error(`Browser selector "${selector}" is ambiguous. Use an exact global browserId from browser_list.`);
240
+ }
241
+ if (matches.length === 0) {
242
+ throw new Error(`No browser matches "${selector}". Run \`mearl browser_list\`.`);
243
+ }
244
+ return matches[0].browserId;
245
+ }
246
+ if (listing.defaultBrowserId)
247
+ return listing.defaultBrowserId;
248
+ const connected = listing.browsers.filter(browser => browser.status === 'connected');
249
+ if (connected.length === 1)
250
+ return connected[0].browserId;
251
+ if (connected.length > 1) {
252
+ throw new Error('Multiple browsers are connected and there is no unique default. Pass --browser <global-id>.');
253
+ }
254
+ return undefined;
255
+ }
256
+ async listConnectors(timeoutSec = this.options.requestTimeoutSec) {
257
+ if (!this.options.serverUrl)
258
+ return { count: 0, connectors: [] };
259
+ return this.requestRemote('connector_list', {}, timeoutSec, undefined, undefined);
260
+ }
261
+ async resolveConnector(selector, timeoutSec) {
262
+ const connectors = (await this.listConnectors(timeoutSec)).connectors;
263
+ const selection = resolveCloudConnectorSelector(connectors, selector);
264
+ if (selection.status === 'ambiguous') {
265
+ throw new Error(`Cloud connector selector "${selector}" is ambiguous. Use an exact connectorId.`);
266
+ }
267
+ if (selection.status !== 'matched') {
268
+ throw new Error(`No cloud connector matches "${selector}".`);
269
+ }
270
+ return selection.connector;
271
+ }
272
+ async listLocalBrowsers(timeoutSec, transport) {
273
+ const result = await invokeLocalBrowser('browser_list', {}, { timeoutSec, ...(transport ? { transport } : {}), controlSource: 'local' });
274
+ return this.applyConfiguredDefault(this.decorateListing(result, { kind: 'local' }));
275
+ }
276
+ async tryLocalBrowserList(timeoutSec) {
277
+ try {
278
+ return await invokeLocalBrowser('browser_list', {}, { timeoutSec, controlSource: 'local' });
279
+ }
280
+ catch {
281
+ return null;
282
+ }
283
+ }
284
+ decorateListing(result, source) {
285
+ const rawBrowsers = Array.isArray(result?.browsers)
286
+ ? result.browsers
287
+ : [];
288
+ const browsers = rawBrowsers.map(browser => decorateBrowser(browser, source));
289
+ return {
290
+ count: browsers.length,
291
+ connectedCount: browsers.filter(browser => browser.status === 'connected').length,
292
+ defaultBrowserId: typeof result?.defaultBrowserId === 'string'
293
+ ? encodeBrowserTarget({
294
+ browser: result.defaultBrowserId,
295
+ ...(source.connectorId ? { connector: source.connectorId } : {}),
296
+ })
297
+ : null,
298
+ browsers,
299
+ };
300
+ }
301
+ applyConfiguredDefault(listing) {
302
+ if (!this.defaultBrowser)
303
+ return listing;
304
+ const matches = findBrowsers(listing.browsers, this.defaultBrowser);
305
+ return {
306
+ ...listing,
307
+ defaultBrowserId: matches.length === 1 ? matches[0].browserId : null,
308
+ };
309
+ }
310
+ async listConnectorBrowsers(selector, timeoutSec) {
311
+ const connector = await this.resolveConnector(selector, timeoutSec);
312
+ const result = await this.requestRemote('browser_list', {}, timeoutSec, undefined, connector.connectorId);
313
+ return this.applyConfiguredDefault(this.decorateListing(result, {
314
+ kind: 'remote',
315
+ connectorId: connector.connectorId,
316
+ connectorName: connector.name,
317
+ }));
318
+ }
319
+ async listAllBrowsers(timeoutSec) {
320
+ const warnings = [];
321
+ const localPromise = this.tryLocalBrowserList(timeoutSec);
322
+ let connectors = [];
323
+ if (this.options.serverUrl) {
324
+ try {
325
+ connectors = (await this.listConnectors(timeoutSec)).connectors;
326
+ }
327
+ catch (error) {
328
+ warnings.push(`cloud-server: ${errorMessage(error)}`);
329
+ }
330
+ }
331
+ const localResult = await localPromise;
332
+ if (!localResult)
333
+ warnings.push('this machine: local browser runtime is unavailable');
334
+ const localConnectorId = localResult ? loadLocalConnectorId() : null;
335
+ const remoteConnectors = localConnectorId
336
+ ? connectors.filter(connector => connector.connectorId !== localConnectorId)
337
+ : connectors;
338
+ const settled = await Promise.allSettled(remoteConnectors.map(async (connector) => ({
339
+ connector,
340
+ listing: await this.requestRemote('browser_list', {}, timeoutSec, undefined, connector.connectorId),
341
+ })));
342
+ const remoteListings = [];
343
+ for (let index = 0; index < settled.length; index += 1) {
344
+ const result = settled[index];
345
+ const connector = remoteConnectors[index];
346
+ if (result.status === 'fulfilled')
347
+ remoteListings.push(result.value);
348
+ else
349
+ warnings.push(`${connector.name}: ${errorMessage(result.reason)}`);
350
+ }
351
+ const listings = [];
352
+ if (localResult)
353
+ listings.push(this.decorateListing(localResult, { kind: 'local' }));
354
+ for (const { connector, listing } of remoteListings) {
355
+ listings.push(this.decorateListing(listing, {
356
+ kind: 'remote',
357
+ connectorId: connector.connectorId,
358
+ connectorName: connector.name,
359
+ }));
360
+ }
361
+ const browsers = listings.flatMap(listing => listing.browsers);
362
+ const defaults = listings
363
+ .map(listing => listing.defaultBrowserId)
364
+ .filter((browserId) => typeof browserId === 'string');
365
+ let defaultBrowserId = null;
366
+ if (this.defaultBrowser &&
367
+ browsers.some(browser => browser.browserId === this.defaultBrowser)) {
368
+ defaultBrowserId = this.defaultBrowser;
369
+ }
370
+ else if (defaults.length === 1) {
371
+ defaultBrowserId = defaults[0];
372
+ }
373
+ return this.applyConfiguredDefault({
374
+ count: browsers.length,
375
+ connectedCount: browsers.filter(browser => browser.status === 'connected').length,
376
+ defaultBrowserId,
377
+ browsers,
378
+ ...(warnings.length > 0 ? { warnings } : {}),
379
+ });
380
+ }
381
+ async invokeLocal(action, data, timeoutSec, browser, transport) {
382
+ const result = await invokeLocalBrowser(action, data, {
383
+ timeoutSec,
384
+ ...(browser ? { browser } : {}),
385
+ ...(transport ? { transport } : {}),
386
+ controlSource: 'local',
387
+ });
388
+ return this.decorateActionResult(action, result, { kind: 'local' });
389
+ }
390
+ async invokeRemote(action, data, timeoutSec, browser, connector) {
391
+ const result = await this.requestRemote(action, data, timeoutSec, browser, connector.connectorId);
392
+ return this.decorateActionResult(action, result, {
393
+ kind: 'remote',
394
+ connectorId: connector.connectorId,
395
+ connectorName: connector.name,
396
+ });
397
+ }
398
+ decorateActionResult(action, result, source) {
399
+ if (action !== 'browser_launch' || typeof result?.browserId !== 'string')
400
+ return result;
401
+ const safeResult = omitTransientBrowserSecrets(result);
402
+ const originalBrowserId = result.browserId;
403
+ return {
404
+ ...safeResult,
405
+ browserId: encodeBrowserTarget({
406
+ browser: originalBrowserId,
407
+ ...(source.connectorId ? { connector: source.connectorId } : {}),
408
+ }),
409
+ originalBrowserId,
410
+ source,
411
+ };
412
+ }
413
+ async ensureConnected() {
414
+ if (this.ws?.readyState === WebSocket.OPEN)
415
+ return;
416
+ if (this.connectPromise)
417
+ return this.connectPromise;
418
+ this.closing = false;
419
+ this.connectPromise = this.connect();
420
+ try {
421
+ await this.connectPromise;
422
+ }
423
+ finally {
424
+ this.connectPromise = null;
425
+ }
426
+ }
427
+ connect() {
428
+ return new Promise((resolve, reject) => {
429
+ if (!this.options.serverUrl) {
430
+ reject(new Error('Cloud server URL is required. Start cloud-server, pass --server, or set MEARL_SERVER_URL.'));
431
+ return;
432
+ }
433
+ const ws = new WebSocket(this.options.serverUrl);
434
+ this.ws = ws;
435
+ let settled = false;
436
+ const timer = setTimeout(() => {
437
+ ws.close();
438
+ if (!settled)
439
+ reject(new Error(`Connection timeout after ${this.options.connectTimeoutSec}s`));
440
+ }, this.options.connectTimeoutSec * 1_000);
441
+ ws.on('open', () => {
442
+ settled = true;
443
+ clearTimeout(timer);
444
+ ws.send(JSON.stringify({ type: 'agent_hello' }) + '\n');
445
+ this.startHeartbeat();
446
+ resolve();
447
+ });
448
+ ws.on('message', data => this.handleMessage(data));
449
+ ws.on('close', (code, reason) => {
450
+ clearTimeout(timer);
451
+ this.rejectAllPending(new Error(`Connection closed (${code}: ${reason.toString()})`));
452
+ if (!this.closing)
453
+ this.ws = null;
454
+ });
455
+ ws.on('error', error => {
456
+ clearTimeout(timer);
457
+ if (!settled)
458
+ reject(error);
459
+ });
460
+ });
461
+ }
462
+ startHeartbeat() {
463
+ if (this.heartbeatTimer)
464
+ clearInterval(this.heartbeatTimer);
465
+ this.heartbeatTimer = setInterval(() => {
466
+ if (this.ws?.readyState === WebSocket.OPEN) {
467
+ this.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }) + '\n');
468
+ }
469
+ }, this.options.heartbeatInterval * 1_000);
470
+ }
471
+ handleMessage(data) {
472
+ this.messageBuffer += data.toString('utf8');
473
+ if (this.messageBuffer.length > MAX_BUFFER_SIZE) {
474
+ this.disconnect();
475
+ return;
476
+ }
477
+ let newlineIndex;
478
+ while ((newlineIndex = this.messageBuffer.indexOf('\n')) !== -1) {
479
+ const line = this.messageBuffer.slice(0, newlineIndex);
480
+ this.messageBuffer = this.messageBuffer.slice(newlineIndex + 1);
481
+ if (!line.trim())
482
+ continue;
483
+ try {
484
+ const message = JSON.parse(line);
485
+ if (!isHeartbeatMessage(message))
486
+ this.handleResponse(message);
487
+ }
488
+ catch {
489
+ // Ignore malformed or unrelated frames; pending requests retain their timeout.
490
+ }
491
+ }
492
+ }
493
+ handleResponse(response) {
494
+ const pending = this.pendingRequests.get(response.id);
495
+ if (!pending)
496
+ return;
497
+ this.pendingRequests.delete(response.id);
498
+ clearTimeout(pending.timer);
499
+ if (response.versionWarning)
500
+ console.warn(response.versionWarning);
501
+ if (response.success)
502
+ pending.resolve(response.data);
503
+ else
504
+ pending.reject(new Error(response.error || 'Unknown error from cloud-server'));
505
+ }
506
+ async requestRemote(action, data, timeoutSec, browser, connector) {
507
+ await this.ensureConnected();
508
+ const ws = this.ws;
509
+ if (!ws || ws.readyState !== WebSocket.OPEN)
510
+ throw new Error('Connection is not open');
511
+ const id = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
512
+ const message = { id, action, data, timeoutSec };
513
+ if (browser)
514
+ message.browser = browser;
515
+ if (connector)
516
+ message.connector = connector;
517
+ const serialized = `${JSON.stringify(message)}\n`;
518
+ const bytes = Buffer.byteLength(serialized, 'utf8');
519
+ if (bytes > MAX_BUFFER_SIZE) {
520
+ throw new Error(`Cloud request exceeds ${MAX_BUFFER_SIZE} byte message limit (${bytes} bytes)`);
521
+ }
522
+ return new Promise((resolve, reject) => {
523
+ const timer = setTimeout(() => {
524
+ this.pendingRequests.delete(id);
525
+ reject(new Error(`Request timeout after ${timeoutSec}s`));
526
+ }, timeoutSec * 1_000 + RESPONSE_GRACE_MS);
527
+ this.pendingRequests.set(id, { resolve, reject, timer });
528
+ ws.send(serialized);
529
+ });
530
+ }
531
+ rejectAllPending(error) {
532
+ for (const pending of this.pendingRequests.values()) {
533
+ clearTimeout(pending.timer);
534
+ pending.reject(error);
535
+ }
536
+ this.pendingRequests.clear();
537
+ }
538
+ }
539
+ let defaultBrowser;
540
+ let defaultConnector;
541
+ export async function invoke(action, data = {}, options = {}) {
542
+ const serverUrl = process.env.MEARL_SERVER_URL ?? loadCloudServerConfig()?.server;
543
+ const client = new UnifiedClient({
544
+ ...(serverUrl ? { serverUrl } : {}),
545
+ defaultBrowser,
546
+ defaultConnector,
547
+ });
548
+ try {
549
+ const result = await client.invoke(action, data, options);
550
+ if (action === 'browser_select_browser') {
551
+ defaultBrowser = result?.selected?.browserId;
552
+ defaultConnector = undefined;
553
+ }
554
+ return result;
555
+ }
556
+ finally {
557
+ client.disconnect();
558
+ }
559
+ }
560
+ export function listCloudConnectors() {
561
+ return invoke('connector_list', {});
562
+ }
@@ -0,0 +1,6 @@
1
+ export interface CloudServerConfig {
2
+ server: string;
3
+ }
4
+ export declare function getCloudServerConfigPath(): string;
5
+ export declare function loadCloudServerConfig(): CloudServerConfig | null;
6
+ export declare function loadLocalConnectorId(): string | null;
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const MEARL_CONFIG_DIR = join(homedir(), '.mearl');
5
+ export function getCloudServerConfigPath() {
6
+ return join(MEARL_CONFIG_DIR, 'cloud-server.json');
7
+ }
8
+ export function loadCloudServerConfig() {
9
+ try {
10
+ const configPath = getCloudServerConfigPath();
11
+ if (!existsSync(configPath))
12
+ return null;
13
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
14
+ return typeof config.server === 'string' && config.server.trim()
15
+ ? { server: config.server }
16
+ : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export function loadLocalConnectorId() {
23
+ const explicit = process.env.MEARL_CONNECTOR_ID?.trim();
24
+ if (explicit)
25
+ return explicit;
26
+ try {
27
+ const config = JSON.parse(readFileSync(join(MEARL_CONFIG_DIR, 'cloud-connector-identity.json'), 'utf8'));
28
+ return typeof config.connectorId === 'string' && config.connectorId.trim()
29
+ ? config.connectorId
30
+ : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.5.1",
4
- "description": "Client SDK & CLI for Mearl — communicate with Chrome Extension via Unix Socket",
3
+ "version": "2.6.0",
4
+ "description": "Unified Mearl SDK & CLI for local and remote browsers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -10,9 +10,25 @@
10
10
  "types": "./dist/index.d.ts",
11
11
  "import": "./dist/index.js"
12
12
  },
13
+ "./local": {
14
+ "types": "./dist/local.d.ts",
15
+ "import": "./dist/local.js"
16
+ },
13
17
  "./action-timeouts": {
14
18
  "types": "./dist/actionTimeouts.d.ts",
15
19
  "import": "./dist/actionTimeouts.js"
20
+ },
21
+ "./commands": {
22
+ "types": "./dist/commands.d.ts",
23
+ "import": "./dist/commands.js"
24
+ },
25
+ "./check": {
26
+ "types": "./dist/check.d.ts",
27
+ "import": "./dist/check.js"
28
+ },
29
+ "./cli": {
30
+ "types": "./dist/cli.d.ts",
31
+ "import": "./dist/cli.js"
16
32
  }
17
33
  },
18
34
  "bin": {
@@ -31,8 +47,13 @@
31
47
  "publishConfig": {
32
48
  "registry": "https://registry.npmjs.org"
33
49
  },
50
+ "dependencies": {
51
+ "ws": "^8.18.0",
52
+ "@mearl/cloud-types": "2.6.0"
53
+ },
34
54
  "devDependencies": {
35
55
  "@types/node": "^24.9.1",
56
+ "@types/ws": "^8.18.1",
36
57
  "typescript": "^5.8.3"
37
58
  },
38
59
  "scripts": {