@mearl/client 2.20.1 → 2.20.3

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/dist/unified.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { invokePageEvalWithArgs } from './pageEvalArguments.js';
1
2
  import { stageFile, exportArtifact } from './artifacts.js';
2
3
  import WebSocket from 'ws';
3
4
  import { DEFAULT_CONNECT_TIMEOUT, DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_REQUEST_TIMEOUT, MAX_BUFFER_SIZE, isHeartbeatMessage, resolveCloudConnectorSelector, } from '@mearl/cloud-types';
4
5
  import { resolveActionTimeoutSec } from './actionTimeouts.js';
5
6
  import { validateRequiredActionTabId, } from './generated-browser-action-protocol.js';
6
- import { invoke as invokeLocalBrowser } from './socket.js';
7
+ import { invoke as invokeLocalBrowser, resolveLocalActionTarget } from './socket.js';
7
8
  import { loadCloudServerConfig, loadLocalConnectorId } from './unifiedConfig.js';
8
9
  const RESPONSE_GRACE_MS = 1_000;
9
10
  const GLOBAL_BROWSER_PREFIX = 'mearl:';
@@ -49,16 +50,6 @@ export function normalizeBrowserSelector(selector) {
49
50
  const decodedTarget = decodeBrowserTarget(selector);
50
51
  return (decodedTarget ? encodeBrowserTarget(decodedTarget) : selector.trim()).toLowerCase();
51
52
  }
52
- export function decodeControlSessionId(id) {
53
- const match = /^mearl-session:([^:]+):(cs_[a-f0-9-]+):(extension|cdp|provider)$/.exec(id);
54
- if (!match)
55
- throw new Error('Invalid session id; reuse session.sessionId from the first response');
56
- return {
57
- browser: decodeURIComponent(match[1]),
58
- id: match[2],
59
- transport: match[3],
60
- };
61
- }
62
53
  function decorateBrowser(browser, source) {
63
54
  const safeBrowser = omitTransientBrowserSecrets(browser);
64
55
  const originalBrowserId = String(browser.browserId);
@@ -102,6 +93,7 @@ function errorMessage(error) {
102
93
  return error instanceof Error ? error.message : String(error);
103
94
  }
104
95
  export class UnifiedClient {
96
+ controlSessions = new Map();
105
97
  options;
106
98
  defaultBrowser;
107
99
  defaultConnector;
@@ -139,7 +131,7 @@ export class UnifiedClient {
139
131
  }
140
132
  async invoke(action, data = {}, options = {}) {
141
133
  if (options.session && options.session !== 'new') {
142
- const session = decodeControlSessionId(options.session);
134
+ const session = await this.resolveControlSession(options.session, options);
143
135
  const selected = decodeBrowserTarget(options.browser);
144
136
  const pinned = decodeBrowserTarget(session.browser);
145
137
  if (options.browser &&
@@ -162,6 +154,25 @@ export class UnifiedClient {
162
154
  throw new Error('--session new requires tab_open, page_snapshot or page_screenshot');
163
155
  options = { ...options, controlSessionId: 'new' };
164
156
  }
157
+ if (action === 'page_eval' && data.args !== undefined) {
158
+ let pinnedOptions = {
159
+ ...options,
160
+ ...(options.browser || !this.defaultBrowser ? {} : { browser: this.defaultBrowser }),
161
+ ...(options.connector ||
162
+ !this.defaultConnector ||
163
+ decodeBrowserTarget(options.browser ?? this.defaultBrowser)
164
+ ? {}
165
+ : { connector: this.defaultConnector }),
166
+ };
167
+ let targetPinned = false;
168
+ return invokePageEvalWithArgs(async (request, transfer) => {
169
+ if (transfer && !targetPinned) {
170
+ pinnedOptions = await this.pinPageEvalTarget(pinnedOptions, request);
171
+ targetPinned = true;
172
+ }
173
+ return this.invokeRouted(action, request, pinnedOptions);
174
+ }, data);
175
+ }
165
176
  if (action === 'page_upload' &&
166
177
  data.files !== undefined &&
167
178
  (!Array.isArray(data.files) ||
@@ -198,9 +209,63 @@ export class UnifiedClient {
198
209
  saveArtifact(artifact, outputPath, options = {}) {
199
210
  return exportArtifact(this, artifact, outputPath, options);
200
211
  }
212
+ async pinPageEvalTarget(options, data) {
213
+ const target = decodeBrowserTarget(options.browser);
214
+ if (target && !options.connector)
215
+ return options;
216
+ const local = options.local ||
217
+ options.transport === 'cdp' ||
218
+ (!this.options.serverUrl && !options.connector);
219
+ if (local) {
220
+ const resolved = resolveLocalActionTarget('page_eval', data, {
221
+ browser: options.browser,
222
+ transport: options.transport,
223
+ });
224
+ if (resolved?.socketPath)
225
+ return { ...options, socketPath: resolved.socketPath };
226
+ if (resolved?.browser)
227
+ return { ...options, browser: encodeBrowserTarget({ browser: resolved.browser }) };
228
+ }
229
+ if (options.connector && !options.browser) {
230
+ const timeoutSec = options.timeoutSec ?? this.options.requestTimeoutSec;
231
+ const connector = await this.resolveConnector(options.connector, timeoutSec);
232
+ const resolved = await this.requestRemote('__resolve_action_target', { action: 'page_eval', data }, timeoutSec, undefined, connector.connectorId);
233
+ if (!resolved?.browser)
234
+ throw new Error('Cannot pin the connector browser for large page_eval arguments. Pass --browser <global-id>.');
235
+ const pinned = {
236
+ ...options,
237
+ browser: encodeBrowserTarget({
238
+ browser: resolved.browser,
239
+ connector: connector.connectorId,
240
+ }),
241
+ };
242
+ delete pinned.connector;
243
+ return pinned;
244
+ }
245
+ const listing = (await this.invokeRouted('browser_list', {}, options));
246
+ const connected = listing.browsers.filter(browser => browser.status === 'connected' && (!local || browser.transport !== 'provider'));
247
+ const implicitHost = options.local || options.transport === 'cdp' || options.connector || !this.options.serverUrl;
248
+ const automatic = connected.find(browser => browser.browserId === listing.defaultBrowserId) ??
249
+ (implicitHost || connected.length === 1 ? connected[0] : undefined);
250
+ const candidates = options.browser
251
+ ? findBrowsers(listing.browsers, options.browser)
252
+ : automatic
253
+ ? [automatic]
254
+ : [];
255
+ if (candidates.length !== 1)
256
+ throw new Error('Large page_eval arguments require a unique connected browser. Pass --browser <global-id>.');
257
+ const browser = candidates[0];
258
+ const pinned = { ...options, browser: browser.browserId };
259
+ delete pinned.connector;
260
+ if (browser.transport === 'cdp' && !decodeBrowserTarget(browser.browserId)?.connector)
261
+ pinned.transport = 'cdp';
262
+ return pinned;
263
+ }
201
264
  async invokeRouted(action, data = {}, options = {}) {
202
265
  validateRequiredActionTabId(action, data);
203
266
  const timeoutSec = options.timeoutSec ?? resolveActionTimeoutSec(action, data, this.options.requestTimeoutSec);
267
+ if (options.socketPath)
268
+ return this.invokeLocal(action, data, timeoutSec, undefined, options.transport, options.controlSessionId, options.socketPath);
204
269
  if (action === 'connector_list')
205
270
  return this.listConnectors(timeoutSec);
206
271
  if (action === 'browser_list') {
@@ -482,11 +547,12 @@ export class UnifiedClient {
482
547
  ...(warnings.length > 0 ? { warnings } : {}),
483
548
  });
484
549
  }
485
- async invokeLocal(action, data, timeoutSec, browser, transport, controlSessionId) {
550
+ async invokeLocal(action, data, timeoutSec, browser, transport, controlSessionId, socketPath) {
486
551
  const result = await invokeLocalBrowser(action, data, {
487
552
  timeoutSec,
488
553
  ...(browser ? { browser } : {}),
489
554
  ...(transport ? { transport } : {}),
555
+ ...(socketPath ? { socketPath } : {}),
490
556
  controlSource: 'local',
491
557
  ...(controlSessionId ? { controlSessionId } : {}),
492
558
  });
@@ -501,17 +567,23 @@ export class UnifiedClient {
501
567
  });
502
568
  }
503
569
  decorateActionResult(action, result, source) {
504
- if (result?.session?.sessionId && !result.session.sessionId.startsWith('mearl-session:')) {
570
+ if (result?.session?.sessionId) {
505
571
  const browserId = encodeBrowserTarget({
506
572
  browser: result.session.browserId,
507
573
  ...(source.connectorId ? { connector: source.connectorId } : {}),
508
574
  });
575
+ if (action !== '__control_session_resolve') {
576
+ this.controlSessions.set(result.session.sessionId, {
577
+ browser: browserId,
578
+ id: result.session.sessionId,
579
+ transport: result.session.transport,
580
+ });
581
+ }
509
582
  result = {
510
583
  ...result,
511
584
  session: {
512
585
  ...result.session,
513
586
  browserId,
514
- sessionId: `mearl-session:${encodeURIComponent(browserId)}:${result.session.sessionId}:${result.session.transport}`,
515
587
  },
516
588
  };
517
589
  }
@@ -539,6 +611,57 @@ export class UnifiedClient {
539
611
  return result;
540
612
  return decorateBrowser(result, source);
541
613
  }
614
+ async resolveControlSession(id, options) {
615
+ if (!/^s_[A-Za-z0-9_-]{8}$/.test(id))
616
+ throw new Error('Invalid session id; reuse session.sessionId from the first response');
617
+ const cached = this.controlSessions.get(id);
618
+ if (cached)
619
+ return cached;
620
+ const timeoutSec = options.timeoutSec ?? 15;
621
+ const localLookup = this.invokeLocal('__control_session_resolve', { sessionId: id }, timeoutSec, undefined);
622
+ const discover = async () => {
623
+ const { connectors } = await this.listConnectors(timeoutSec);
624
+ return Promise.allSettled(connectors.map(connector => this.invokeRemote('__control_session_resolve', { sessionId: id }, timeoutSec, undefined, connector)));
625
+ };
626
+ const [local, remote] = await Promise.allSettled([
627
+ localLookup,
628
+ this.options.serverUrl && !options.local && !options.transport
629
+ ? discover()
630
+ : Promise.resolve([]),
631
+ ]);
632
+ const localSession = local.status === 'fulfilled' ? local.value?.session : undefined;
633
+ const localTarget = decodeBrowserTarget(localSession?.browserId);
634
+ const localConnectorId = localSession?.sessionId === id ? loadLocalConnectorId() : null;
635
+ const results = [local, ...(remote.status === 'fulfilled' ? remote.value : [remote])].filter(result => {
636
+ if (result === local || result.status === 'rejected')
637
+ return true;
638
+ const session = result.value?.session;
639
+ const target = decodeBrowserTarget(session?.browserId);
640
+ // The same host can be reached over both its local socket and its cloud connector.
641
+ return !(localConnectorId &&
642
+ target?.connector === localConnectorId &&
643
+ target.browser === localTarget?.browser &&
644
+ session?.sessionId === id &&
645
+ session.transport === localSession.transport);
646
+ });
647
+ const sessions = results.flatMap(result => result.status === 'fulfilled' && result.value?.session?.sessionId === id
648
+ ? [result.value.session]
649
+ : []);
650
+ const ambiguous = results.some(result => result.status === 'rejected' &&
651
+ errorMessage(result.reason).startsWith('CONTROL_SESSION_AMBIGUOUS'));
652
+ if (sessions.length > 1 || ambiguous) {
653
+ this.controlSessions.delete(id);
654
+ throw new Error('CONTROL_SESSION_AMBIGUOUS: multiple hosts own this id');
655
+ }
656
+ const session = sessions[0];
657
+ if (!session) {
658
+ const errors = results.flatMap(result => result.status === 'rejected' ? [errorMessage(result.reason)] : []);
659
+ throw new Error(`CONTROL_SESSION_NOT_FOUND: ${id}${errors.length ? ` (${errors.join('; ')})` : ''}`);
660
+ }
661
+ const target = { browser: session.browserId, id, transport: session.transport };
662
+ this.controlSessions.set(id, target);
663
+ return target;
664
+ }
542
665
  async ensureConnected() {
543
666
  if (this.ws?.readyState === WebSocket.OPEN)
544
667
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.20.1",
3
+ "version": "2.20.3",
4
4
  "description": "Unified Mearl SDK & CLI for local and remote browsers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -59,8 +59,8 @@
59
59
  "commander": "^15.0.0",
60
60
  "fastest-levenshtein": "^1.0.16",
61
61
  "ws": "^8.18.0",
62
- "@mearl/cloud-types": "2.20.1",
63
- "@mearl/provider": "2.20.1"
62
+ "@mearl/cloud-types": "2.20.3",
63
+ "@mearl/provider": "2.20.3"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.9.1",