@mearl/client 2.20.2 → 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/README.md +3 -1
- package/dist/commands.d.ts +18 -2
- package/dist/commands.js +27 -4
- package/dist/generated-browser-action-protocol.d.ts +13 -1
- package/dist/generated-browser-action-protocol.js +20 -0
- package/dist/pageEvalArguments.d.ts +3 -0
- package/dist/pageEvalArguments.js +47 -0
- package/dist/plugin.d.ts +1 -0
- package/dist/plugin.js +1 -0
- package/dist/pluginPageBundle.js +22 -72
- package/dist/socket.d.ts +7 -0
- package/dist/socket.js +74 -10
- package/dist/unified.d.ts +3 -5
- package/dist/unified.js +138 -15
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -76,6 +76,8 @@ mearl browser_release
|
|
|
76
76
|
|
|
77
77
|
多步骤任务首次 `tab_open` 或 `page_snapshot` 使用 `--session new`,响应在原结果旁附带 `session`,后续用其 `sessionId` 自动路由,无需额外创建/查询 action。Session 绑定一个浏览器与传输;页面首次使用自动关联,复用页面与新建页面分别记录。同一页面只允许一个会话控制,旧调用不能绕过占用;Session 不隔离 Cookie 或存储。
|
|
78
78
|
|
|
79
|
+
`sessionId` 是形如 `s_aB3d_E-f` 的短 ID。会话及其浏览器、传输映射保存在常驻控制宿主的内存中;新的 CLI 进程会内部查询本机和远端宿主完成路由,无需调用方保存或传递路由信息,也不写入会话映射文件。控制宿主退出后会话失效。
|
|
80
|
+
|
|
79
81
|
旧调用不必传 `--session`,现有动作、`run_actions` 批次和上传 `filePaths` 参数继续可用。活动会话存在时,旧调用不能操作其占用页面、执行浏览器级释放/关闭或修改共享设置;旧批次中的开页/关闭浏览器需改为独立调用。CDP Mock 按标签页生效,跨页使用同一 Mock 时需分别配置。
|
|
80
82
|
|
|
81
83
|
```bash
|
|
@@ -271,7 +273,7 @@ export default function register(app) {
|
|
|
271
273
|
`app.command(signature, handler, description?, examples?)` 注册 CLI、SDK 和 Agent 可调用的业务命令。
|
|
272
274
|
`app.toolboxCommand(signature, handler, description?, examples?)` 注册仅供工具页面调用的命令,参数和 handler 契约相同;
|
|
273
275
|
它不出现在 CLI 帮助中,CLI(包括 JSON payload)、SDK、MCP 及插件间的 `app.invoke` 都不能调用。
|
|
274
|
-
工具页面通过 `window.mearl.invoke` 或 `window.mearl.start` 执行;两类命令都必须显式列在 `toolbox.page.commands` 中才能供页面调用。
|
|
276
|
+
工具页面通过 `window.mearl.invoke(command, input)` 或 `window.mearl.invoke('host.operation.start', { command, input })` 执行;两类命令都必须显式列在 `toolbox.page.commands` 中才能供页面调用。
|
|
275
277
|
注册的 `toolboxCommand` 若没有列入该白名单,插件加载会失败。分块上传、分块读取和页面临时资源释放适合使用它;
|
|
276
278
|
对 Agent 有独立业务价值的查询、导入、压缩、比较等完整操作使用 `app.command`。
|
|
277
279
|
Python 入口也支持 `app.toolboxCommand(...)`,包括 `@app.toolboxCommand(..., description="...")` 装饰器。
|
package/dist/commands.d.ts
CHANGED
|
@@ -722,7 +722,19 @@ export declare const COMMANDS: readonly [{
|
|
|
722
722
|
readonly name: "expression";
|
|
723
723
|
readonly type: "string";
|
|
724
724
|
readonly required: true;
|
|
725
|
-
readonly description: "要执行的 JS
|
|
725
|
+
readonly description: "要执行的 JS 表达式;传 args 时须求值为函数";
|
|
726
|
+
}, {
|
|
727
|
+
readonly name: "args";
|
|
728
|
+
readonly type: "unknown[]";
|
|
729
|
+
readonly description: "传给页面函数的 JSON 参数;SDK/CLI 自动处理大参数分块,保持指定执行环境";
|
|
730
|
+
}, {
|
|
731
|
+
readonly name: "executionWorld";
|
|
732
|
+
readonly type: "string";
|
|
733
|
+
readonly description: "main(默认)读取网页 JS;isolated 使用命名隔离环境,扩展连接无需 debugger";
|
|
734
|
+
}, {
|
|
735
|
+
readonly name: "worldId";
|
|
736
|
+
readonly type: "string";
|
|
737
|
+
readonly description: "isolated 必填,小写字母开头,允许数字和连字符,最长 64 字符";
|
|
726
738
|
}, {
|
|
727
739
|
readonly name: "awaitPromise";
|
|
728
740
|
readonly type: "boolean";
|
|
@@ -953,6 +965,10 @@ export declare const COMMANDS: readonly [{
|
|
|
953
965
|
readonly name: "quality";
|
|
954
966
|
readonly type: "number";
|
|
955
967
|
readonly description: "JPEG 压缩质量(0-100),默认 80";
|
|
968
|
+
}, {
|
|
969
|
+
readonly name: "captureMode";
|
|
970
|
+
readonly type: "\"cdp\" | \"visible\"";
|
|
971
|
+
readonly description: "默认 cdp;visible 使用活动标签页截图,普通网页无需 debugger;设备模拟复用模拟会话并截取业务视口,不支持 selector";
|
|
956
972
|
}, {
|
|
957
973
|
readonly name: "tabId";
|
|
958
974
|
readonly type: "number";
|
|
@@ -960,7 +976,7 @@ export declare const COMMANDS: readonly [{
|
|
|
960
976
|
readonly description: "目标标签页 ID";
|
|
961
977
|
}];
|
|
962
978
|
readonly notes: readonly ["使用 --output <path> 保存图片;省略时 stdout 返回含 base64 data 的 JSON,避免直接将其加载为文本。"];
|
|
963
|
-
readonly result: readonly ["meta 包含 format、width、height、size、url、title
|
|
979
|
+
readonly result: readonly ["meta 包含 format、width、height、size、url、title;宽高为实际截图的物理像素。"];
|
|
964
980
|
readonly examples: ["page_screenshot --payload '{\"tabId\":12345}' --output ./screenshot.png", "page_screenshot --payload '{\"tabId\":12345,\"selector\":\".product-card\"}' --output ./card.png"];
|
|
965
981
|
}, {
|
|
966
982
|
readonly name: "set_device_emulation";
|
package/dist/commands.js
CHANGED
|
@@ -787,7 +787,27 @@ export const COMMANDS = [
|
|
|
787
787
|
name: 'page_eval',
|
|
788
788
|
description: '在页面上下文中执行任意 JavaScript 表达式',
|
|
789
789
|
params: [
|
|
790
|
-
{
|
|
790
|
+
{
|
|
791
|
+
name: 'expression',
|
|
792
|
+
type: 'string',
|
|
793
|
+
required: true,
|
|
794
|
+
description: '要执行的 JS 表达式;传 args 时须求值为函数',
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: 'args',
|
|
798
|
+
type: 'unknown[]',
|
|
799
|
+
description: '传给页面函数的 JSON 参数;SDK/CLI 自动处理大参数分块,保持指定执行环境',
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
name: 'executionWorld',
|
|
803
|
+
type: 'string',
|
|
804
|
+
description: 'main(默认)读取网页 JS;isolated 使用命名隔离环境,扩展连接无需 debugger',
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
name: 'worldId',
|
|
808
|
+
type: 'string',
|
|
809
|
+
description: 'isolated 必填,小写字母开头,允许数字和连字符,最长 64 字符',
|
|
810
|
+
},
|
|
791
811
|
{
|
|
792
812
|
name: 'awaitPromise',
|
|
793
813
|
type: 'boolean',
|
|
@@ -1049,14 +1069,17 @@ export const COMMANDS = [
|
|
|
1049
1069
|
{ name: 'selector', type: 'string', description: 'CSS 选择器,提供后只截取该元素区域' },
|
|
1050
1070
|
{ name: 'format', type: '"png" | "jpeg"', description: '图片格式,默认 png' },
|
|
1051
1071
|
{ name: 'quality', type: 'number', description: 'JPEG 压缩质量(0-100),默认 80' },
|
|
1072
|
+
{
|
|
1073
|
+
name: 'captureMode',
|
|
1074
|
+
type: '"cdp" | "visible"',
|
|
1075
|
+
description: '默认 cdp;visible 使用活动标签页截图,普通网页无需 debugger;设备模拟复用模拟会话并截取业务视口,不支持 selector',
|
|
1076
|
+
},
|
|
1052
1077
|
{ name: 'tabId', type: 'number', required: true, description: '目标标签页 ID' },
|
|
1053
1078
|
],
|
|
1054
1079
|
notes: [
|
|
1055
1080
|
'使用 --output <path> 保存图片;省略时 stdout 返回含 base64 data 的 JSON,避免直接将其加载为文本。',
|
|
1056
1081
|
],
|
|
1057
|
-
result: [
|
|
1058
|
-
'meta 包含 format、width、height、size、url、title;宽高为物理像素(CSS 尺寸 × DPR)。',
|
|
1059
|
-
],
|
|
1082
|
+
result: ['meta 包含 format、width、height、size、url、title;宽高为实际截图的物理像素。'],
|
|
1060
1083
|
examples: [
|
|
1061
1084
|
`page_screenshot --payload '{"tabId":12345}' --output ./screenshot.png`,
|
|
1062
1085
|
`page_screenshot --payload '{"tabId":12345,"selector":".product-card"}' --output ./card.png`,
|
|
@@ -437,6 +437,7 @@ export interface PageScreenshotParams {
|
|
|
437
437
|
selector?: string;
|
|
438
438
|
format?: 'png' | 'jpeg';
|
|
439
439
|
quality?: number;
|
|
440
|
+
captureMode?: 'cdp' | 'visible';
|
|
440
441
|
}
|
|
441
442
|
export declare const BROWSER_DEVICE_PRESET_NAMES: readonly ["iphone-duo-folded", "iphone-duo-unfolded", "iphone-17-pro", "iphone-17e", "pixel-11", "ipad-mini-a17-pro", "galaxy-s26", "pura-x-max", "iphone-15-pro", "iphone-se", "pixel-8", "ipad-mini", "galaxy-s23"];
|
|
442
443
|
export type BrowserDevicePresetName = (typeof BROWSER_DEVICE_PRESET_NAMES)[number];
|
|
@@ -615,11 +616,22 @@ export interface PageHoverParams {
|
|
|
615
616
|
export interface PageEvalParams {
|
|
616
617
|
tabId: number;
|
|
617
618
|
expression: string;
|
|
619
|
+
/** When provided, expression must evaluate to a function called with these JSON arguments. */
|
|
620
|
+
args?: unknown[];
|
|
618
621
|
frameId?: string;
|
|
619
622
|
awaitPromise?: boolean;
|
|
620
|
-
observe?: PageObservationWaitOptions;
|
|
623
|
+
observe?: PageObservationWaitOptions | false;
|
|
621
624
|
diagnostics?: PageDiagnosticsSetting;
|
|
625
|
+
executionWorld?: 'main' | 'isolated';
|
|
626
|
+
worldId?: string;
|
|
627
|
+
}
|
|
628
|
+
export declare function resolvePageEvalExpression(params: Pick<PageEvalParams, 'expression' | 'args'>): string;
|
|
629
|
+
export interface PageUserScriptParams {
|
|
630
|
+
tabId: number;
|
|
631
|
+
worldId: string;
|
|
632
|
+
code: string;
|
|
622
633
|
}
|
|
634
|
+
export declare function validatePageUserScriptParams(params: PageUserScriptParams): void;
|
|
623
635
|
export interface PageSnapshotQuery {
|
|
624
636
|
text?: string;
|
|
625
637
|
role?: string;
|
|
@@ -50,6 +50,26 @@ export const APP_PROFILE_NAMES = [
|
|
|
50
50
|
'didi',
|
|
51
51
|
'easybike',
|
|
52
52
|
];
|
|
53
|
+
export function resolvePageEvalExpression(params) {
|
|
54
|
+
if (typeof params.expression !== 'string' || !params.expression.trim())
|
|
55
|
+
throw new Error('Invalid expression');
|
|
56
|
+
if (params.args === undefined)
|
|
57
|
+
return params.expression;
|
|
58
|
+
if (!Array.isArray(params.args))
|
|
59
|
+
throw new Error('page_eval args must be an array');
|
|
60
|
+
return `(${params.expression})(...JSON.parse(${JSON.stringify(JSON.stringify(params.args))}))`;
|
|
61
|
+
}
|
|
62
|
+
export function validatePageUserScriptParams(params) {
|
|
63
|
+
if (!Number.isSafeInteger(params?.tabId) || params.tabId < 0) {
|
|
64
|
+
throw new Error('Invalid target tab');
|
|
65
|
+
}
|
|
66
|
+
if (typeof params.worldId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(params.worldId)) {
|
|
67
|
+
throw new Error('Invalid user script worldId');
|
|
68
|
+
}
|
|
69
|
+
if (typeof params.code !== 'string' || !params.code.trim() || params.code.length > 256 * 1024) {
|
|
70
|
+
throw new Error('User script code must be non-empty and within 256 KiB characters');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
53
73
|
export const TAB_ID_REQUIRED_ACTIONS = [
|
|
54
74
|
'tab_checkpoint',
|
|
55
75
|
'capture_checkpoint',
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type PageEvalParams } from './generated-browser-action-protocol.js';
|
|
2
|
+
/** Serialize page_eval arguments within transport limits while retaining the caller's execution world. */
|
|
3
|
+
export declare function invokePageEvalWithArgs(invoke: (payload: Record<string, unknown>, transfer?: boolean) => Promise<any>, params: PageEvalParams): Promise<any>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { validatePageUserScriptParams, resolvePageEvalExpression, } from './generated-browser-action-protocol.js';
|
|
3
|
+
const CHUNK_CHARACTERS = 48 * 1024;
|
|
4
|
+
/** Serialize page_eval arguments within transport limits while retaining the caller's execution world. */
|
|
5
|
+
export async function invokePageEvalWithArgs(invoke, params) {
|
|
6
|
+
if (!Number.isSafeInteger(params.tabId) || params.tabId < 0)
|
|
7
|
+
throw new Error('Invalid target tab');
|
|
8
|
+
resolvePageEvalExpression({ expression: params.expression, args: [] });
|
|
9
|
+
if (!Array.isArray(params.args))
|
|
10
|
+
throw new Error('page_eval args must be an array');
|
|
11
|
+
if (params.executionWorld !== undefined && !['main', 'isolated'].includes(params.executionWorld))
|
|
12
|
+
throw new Error('Invalid page executionWorld');
|
|
13
|
+
if (params.executionWorld === 'isolated')
|
|
14
|
+
validatePageUserScriptParams({
|
|
15
|
+
tabId: params.tabId,
|
|
16
|
+
worldId: params.worldId ?? '',
|
|
17
|
+
code: params.expression,
|
|
18
|
+
});
|
|
19
|
+
const args = JSON.stringify(params.args);
|
|
20
|
+
if (args.length > 64 * 1024 * 1024)
|
|
21
|
+
throw new Error('Page script arguments must fit within 64 MiB characters');
|
|
22
|
+
const { args: _args, ...request } = params;
|
|
23
|
+
const evaluate = async (expression, transfer = false) => {
|
|
24
|
+
const payload = {
|
|
25
|
+
...request,
|
|
26
|
+
...(transfer ? { observe: false, diagnostics: false } : {}),
|
|
27
|
+
expression,
|
|
28
|
+
};
|
|
29
|
+
const result = await (transfer ? invoke(payload, true) : invoke(payload));
|
|
30
|
+
if (transfer && result?.action?.success === false)
|
|
31
|
+
throw new Error(result.action.error || 'page_eval failed');
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
if (args.length + params.expression.length <= CHUNK_CHARACTERS) {
|
|
35
|
+
return evaluate(resolvePageEvalExpression(params));
|
|
36
|
+
}
|
|
37
|
+
const key = JSON.stringify('__mearlPageScriptTransfer_' + randomUUID());
|
|
38
|
+
try {
|
|
39
|
+
for (let offset = 0; offset < args.length; offset += CHUNK_CHARACTERS) {
|
|
40
|
+
await evaluate(`void (window[${key}] = (window[${key}] || '') + ${JSON.stringify(args.slice(offset, offset + CHUNK_CHARACTERS))})`, true);
|
|
41
|
+
}
|
|
42
|
+
return await evaluate(`(() => { const args = JSON.parse(window[${key}]); delete window[${key}]; return (${params.expression})(...args); })()`);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await evaluate(`delete window[${key}]`, true).catch(() => { });
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { invokePageEvalWithArgs } from './pageEvalArguments.js';
|
|
1
2
|
import { type OfficialPluginInstaller } from './officialPluginInstaller.js';
|
|
2
3
|
import { type PluginToolboxContribution, type PluginRegistration } from './pluginRegistry.js';
|
|
3
4
|
import type { UnifiedInvokeOptions } from './unified.js';
|
package/dist/plugin.js
CHANGED
package/dist/pluginPageBundle.js
CHANGED
|
@@ -29,6 +29,8 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
|
|
|
29
29
|
let port = null;
|
|
30
30
|
let requestSequence = 0;
|
|
31
31
|
const pending = new Map();
|
|
32
|
+
const pageEventListeners = new Map();
|
|
33
|
+
let subscriptionSequence = 0;
|
|
32
34
|
let resolveReady;
|
|
33
35
|
const ready = new Promise(resolve => { resolveReady = resolve; });
|
|
34
36
|
|
|
@@ -76,64 +78,25 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
|
|
|
76
78
|
};
|
|
77
79
|
|
|
78
80
|
const invoke = async (command, input = {}) => {
|
|
79
|
-
if (typeof command !== 'string' || !/^[a-z][a-z0-9-]*$/.test(command))
|
|
80
|
-
throw new Error('Invalid plugin command');
|
|
81
|
-
}
|
|
81
|
+
if (typeof command !== 'string' || (!/^[a-z][a-z0-9-]*$/.test(command) && !/^host\.[a-z][a-zA-Z]*(?:\.[a-z][a-zA-Z]*)*$/.test(command))) throw new Error('Invalid command');
|
|
82
82
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
83
83
|
throw new Error('Plugin command input must be an object');
|
|
84
84
|
}
|
|
85
85
|
return requestHost('invoke', { command, input });
|
|
86
86
|
};
|
|
87
87
|
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
throw new Error('Invalid plugin operation id');
|
|
101
|
-
}
|
|
102
|
-
return requestHost('invoke-operation', { operationId });
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const runningOperations = async () => requestHost('invoke-operations');
|
|
106
|
-
const readClipboard = async () => requestHost('clipboard-read');
|
|
107
|
-
const getActiveTab = async () => requestHost('active-tab');
|
|
108
|
-
|
|
109
|
-
const selectDirectory = async () => {
|
|
110
|
-
const path = await requestHost('select-directory');
|
|
111
|
-
if (path !== null && typeof path !== 'string') {
|
|
112
|
-
throw new Error('Directory selection returned an invalid path');
|
|
113
|
-
}
|
|
114
|
-
return path;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const copyText = async text => {
|
|
118
|
-
if (typeof text !== 'string' || text.length > 1000000) {
|
|
119
|
-
throw new Error('Clipboard text must be a string no longer than 1000000 characters');
|
|
120
|
-
}
|
|
121
|
-
await requestHost('clipboard-write', { text });
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const copyImage = async data => {
|
|
125
|
-
if (typeof data !== 'string' || data.length > 16 * 1024 * 1024 ||
|
|
126
|
-
!/^data:image\/(png|jpeg|webp);base64,/.test(data)) {
|
|
127
|
-
throw new Error('Clipboard image must be a PNG, JPEG or WebP data URL within 16 MiB');
|
|
128
|
-
}
|
|
129
|
-
await requestHost('clipboard-image-write', { data });
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const openExternalLink = async url => {
|
|
133
|
-
if (typeof url !== 'string' || !url || url.length > 8192) {
|
|
134
|
-
throw new Error('External link must be a non-empty URL no longer than 8192 characters');
|
|
135
|
-
}
|
|
136
|
-
await requestHost('open-external-link', { url });
|
|
88
|
+
const subscribe = async (source, target, listener) => {
|
|
89
|
+
if (source !== 'page' || !target || !Number.isSafeInteger(target.tabId) || target.tabId < 0 ||
|
|
90
|
+
typeof target.runtimeId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(target.runtimeId) ||
|
|
91
|
+
typeof listener !== 'function') throw new Error('Invalid event subscription');
|
|
92
|
+
const subscriptionId = String(++subscriptionSequence);
|
|
93
|
+
pageEventListeners.set(subscriptionId, listener);
|
|
94
|
+
try { await requestHost('subscribe', { source, target, subscriptionId }); }
|
|
95
|
+
catch (error) { pageEventListeners.delete(subscriptionId); throw error; }
|
|
96
|
+
return () => {
|
|
97
|
+
if (!pageEventListeners.delete(subscriptionId)) return;
|
|
98
|
+
void requestHost('unsubscribe', { subscriptionId }).catch(() => {});
|
|
99
|
+
};
|
|
137
100
|
};
|
|
138
101
|
|
|
139
102
|
const handleExternalLinkClick = event => {
|
|
@@ -150,28 +113,11 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
|
|
|
150
113
|
}
|
|
151
114
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
152
115
|
event.preventDefault();
|
|
153
|
-
void
|
|
116
|
+
void invoke('host.link.open', { url: url.href }).catch(error => {
|
|
154
117
|
console.error('Failed to open external link', error);
|
|
155
118
|
});
|
|
156
119
|
};
|
|
157
120
|
|
|
158
|
-
const toast = async (message, options = {}) => {
|
|
159
|
-
if (typeof message !== 'string' || !message.trim() || message.length > 4000) {
|
|
160
|
-
throw new Error('Toast message must be a non-empty string no longer than 4000 characters');
|
|
161
|
-
}
|
|
162
|
-
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
163
|
-
throw new Error('Toast options must be an object');
|
|
164
|
-
}
|
|
165
|
-
const type = options.type ?? 'info';
|
|
166
|
-
const duration = options.duration ?? 3000;
|
|
167
|
-
if (!['success', 'error', 'info'].includes(type)) throw new Error('Invalid toast type');
|
|
168
|
-
if (!Number.isInteger(duration) || duration < 1000 || duration > 30000) {
|
|
169
|
-
throw new Error('Toast duration must be between 1000 and 30000 milliseconds');
|
|
170
|
-
}
|
|
171
|
-
await ready;
|
|
172
|
-
port.postMessage({ type: 'toast', message: message.trim(), toastType: type, duration });
|
|
173
|
-
};
|
|
174
|
-
|
|
175
121
|
const agentPanelLayout = options => {
|
|
176
122
|
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
177
123
|
throw new Error('Agent panel options must be an object');
|
|
@@ -451,7 +397,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
|
|
|
451
397
|
return query;
|
|
452
398
|
};
|
|
453
399
|
|
|
454
|
-
const api = {
|
|
400
|
+
const api = { invoke, subscribe, ready: () => ready, runAgentTask, setAgentGuides, setAgentPanelVisible, setQueryParam };
|
|
455
401
|
const ui = globalThis.__mearlToolboxUi;
|
|
456
402
|
if (ui !== undefined) {
|
|
457
403
|
if (!ui || typeof ui !== 'object' || ui.runtime !== 'preact') {
|
|
@@ -491,6 +437,10 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
|
|
|
491
437
|
port = nextPort;
|
|
492
438
|
port.onmessage = responseEvent => {
|
|
493
439
|
const response = responseEvent.data;
|
|
440
|
+
if (response?.type === 'event' && typeof response.subscriptionId === 'string') {
|
|
441
|
+
pageEventListeners.get(response.subscriptionId)?.(response.event, response.metadata);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
494
444
|
if (response?.type === 'context') {
|
|
495
445
|
applyContext(response.context);
|
|
496
446
|
return;
|
|
@@ -516,7 +466,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
|
|
|
516
466
|
if (!request) return;
|
|
517
467
|
pending.delete(response.requestId);
|
|
518
468
|
if (response.ok) request.resolve(response.result);
|
|
519
|
-
else request.reject(new Error(response.error || 'Plugin command failed'));
|
|
469
|
+
else request.reject(Object.assign(new Error(response.error || 'Plugin command failed'), { handledByHost: response.handledByHost === true }));
|
|
520
470
|
};
|
|
521
471
|
port.start();
|
|
522
472
|
resolveReady(context);
|
package/dist/socket.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type ControlSource = 'local' | 'remote';
|
|
|
18
18
|
type CompatibleControlSource = ControlSource | 'cloud';
|
|
19
19
|
export interface InvokeOptions {
|
|
20
20
|
controlSessionId?: string;
|
|
21
|
+
/** Pin a known local endpoint for a sequence of transport requests. */
|
|
22
|
+
socketPath?: string;
|
|
21
23
|
/** 超时时间(秒),默认 15 */
|
|
22
24
|
timeoutSec?: number;
|
|
23
25
|
/** Force CDP transport instead of extension */
|
|
@@ -31,6 +33,11 @@ export interface InvokeOptions {
|
|
|
31
33
|
export declare function setDefaultBrowser(selector: string | undefined): void;
|
|
32
34
|
export declare function getDefaultBrowser(): string | undefined;
|
|
33
35
|
export declare function decorateBrowserListResult(result: Record<string, any>): Record<string, any>;
|
|
36
|
+
/** Resolve an existing local route without executing the action. */
|
|
37
|
+
export declare function resolveLocalActionTarget(action: string, data: Record<string, any>, options?: InvokeOptions): {
|
|
38
|
+
browser?: string;
|
|
39
|
+
socketPath?: string;
|
|
40
|
+
} | undefined;
|
|
34
41
|
/**
|
|
35
42
|
* 向 native host 发送一个 action 请求并等待响应。
|
|
36
43
|
*
|
package/dist/socket.js
CHANGED
|
@@ -18,6 +18,7 @@ import { readLiveProvider } from '@mearl/provider/runtime';
|
|
|
18
18
|
import { resolveFileRefs } from './fileRefs.js';
|
|
19
19
|
import { listLiveBrowsers, findBrowser, describeBrowser, } from './browserRegistry.js';
|
|
20
20
|
import { resolveActionTimeoutSec } from './actionTimeouts.js';
|
|
21
|
+
import { isBrowserCapabilityAction } from './generated-browser-action-protocol.js';
|
|
21
22
|
const require = createRequire(import.meta.url);
|
|
22
23
|
export const CLIENT_VERSION = require('../package.json').version;
|
|
23
24
|
const MAX_SOCKET_BUFFER_SIZE = 64 * 1024 * 1024;
|
|
@@ -160,6 +161,24 @@ function supportsAction(browser, action, data) {
|
|
|
160
161
|
return (browser.transport !== 'provider' ||
|
|
161
162
|
browser.capabilities?.actions.some(capability => capability === action) === true);
|
|
162
163
|
}
|
|
164
|
+
function routableBrowsers(browsers, action, data, transport) {
|
|
165
|
+
return browsers.filter(browser => isConnectedBrowser(browser) &&
|
|
166
|
+
(action !== 'browser_list' || browser.transport !== 'provider') &&
|
|
167
|
+
!(transport === 'cdp' && browser.transport === 'provider') &&
|
|
168
|
+
supportsAction(browser, action, data));
|
|
169
|
+
}
|
|
170
|
+
/** Resolve an existing local route without executing the action. */
|
|
171
|
+
export function resolveLocalActionTarget(action, data, options = {}) {
|
|
172
|
+
const selector = options.browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
|
|
173
|
+
const socketPath = options.socketPath ?? process.env.MEARL_SOCKET_PATH?.trim();
|
|
174
|
+
if (socketPath && (options.socketPath || !selector))
|
|
175
|
+
return { socketPath };
|
|
176
|
+
const browsers = listLiveBrowsers();
|
|
177
|
+
const target = selector
|
|
178
|
+
? findBrowser(browsers, selector)
|
|
179
|
+
: routableBrowsers(browsers, action, data, options.transport)[0];
|
|
180
|
+
return target ? { browser: target.browserId } : undefined;
|
|
181
|
+
}
|
|
163
182
|
function selectBrowserResult(data) {
|
|
164
183
|
const selector = typeof data.browser === 'string' ? data.browser.trim() : '';
|
|
165
184
|
if (!selector) {
|
|
@@ -329,6 +348,17 @@ function removeStaleLegacySocket(identity) {
|
|
|
329
348
|
* legacy well-known socket,并在 socket 不存在或已残留时自动拉起 native host。
|
|
330
349
|
*/
|
|
331
350
|
export function invoke(action, data = {}, options = {}) {
|
|
351
|
+
if (action === '__resolve_action_target') {
|
|
352
|
+
if (!isBrowserCapabilityAction(data.action))
|
|
353
|
+
return Promise.reject(new Error('Invalid browser capability action'));
|
|
354
|
+
const target = resolveLocalActionTarget(data.action, data.data ?? {}, options);
|
|
355
|
+
const browser = target?.browser ??
|
|
356
|
+
(target?.socketPath
|
|
357
|
+
? listLiveBrowsers().find(item => item.socketPath === target.socketPath &&
|
|
358
|
+
supportsAction(item, data.action, data.data ?? {}))?.browserId
|
|
359
|
+
: undefined);
|
|
360
|
+
return Promise.resolve(browser ? { browser } : null);
|
|
361
|
+
}
|
|
332
362
|
if (action === 'browser_select_browser') {
|
|
333
363
|
try {
|
|
334
364
|
return Promise.resolve(selectBrowserResult(data));
|
|
@@ -338,14 +368,50 @@ export function invoke(action, data = {}, options = {}) {
|
|
|
338
368
|
}
|
|
339
369
|
}
|
|
340
370
|
const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser, controlSource = 'local', } = options;
|
|
371
|
+
if (action === '__control_session_resolve') {
|
|
372
|
+
return resolveControlSession(data.sessionId, timeoutSec, controlSource);
|
|
373
|
+
}
|
|
341
374
|
// 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
|
|
342
375
|
const resolvedData = resolveFileRefs(data);
|
|
343
376
|
Object.defineProperty(resolvedData, SESSION_OPTION, { value: options.controlSessionId });
|
|
344
377
|
if (action === 'browser_list') {
|
|
345
|
-
return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
|
|
378
|
+
return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource, options.socketPath).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
|
|
346
379
|
}
|
|
347
380
|
const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
|
|
348
|
-
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
|
|
381
|
+
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource, options.socketPath);
|
|
382
|
+
}
|
|
383
|
+
/** Session ownership lives in the control hosts and survives individual CLI connections. */
|
|
384
|
+
async function resolveControlSession(sessionId, timeoutSec, controlSource) {
|
|
385
|
+
const browsers = listLiveBrowsers();
|
|
386
|
+
const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
|
|
387
|
+
const sockets = configuredSocketPath
|
|
388
|
+
? [configuredSocketPath]
|
|
389
|
+
: [
|
|
390
|
+
...new Set(browsers
|
|
391
|
+
.filter(browser => browser.transport !== 'provider')
|
|
392
|
+
.map(browser => browser.socketPath)),
|
|
393
|
+
];
|
|
394
|
+
const provider = configuredSocketPath
|
|
395
|
+
? undefined
|
|
396
|
+
: browsers.find(browser => browser.transport === 'provider');
|
|
397
|
+
if (!sockets.length && !provider)
|
|
398
|
+
sockets.push(SOCKET_PATH);
|
|
399
|
+
const requests = sockets.map(socketPath => invokeOnce('__control_session_resolve', { sessionId }, timeoutSec, undefined, socketPath, controlSource));
|
|
400
|
+
if (provider) {
|
|
401
|
+
requests.push(invokeOnce('__control_session_resolve', { sessionId }, timeoutSec, undefined, provider.socketPath, controlSource, provider.browserId));
|
|
402
|
+
}
|
|
403
|
+
const results = await Promise.allSettled(requests);
|
|
404
|
+
const sessions = results.flatMap(result => result.status === 'fulfilled' && result.value?.session?.sessionId === sessionId
|
|
405
|
+
? [result.value.session]
|
|
406
|
+
: []);
|
|
407
|
+
if (sessions.length > 1)
|
|
408
|
+
throw new Error('CONTROL_SESSION_AMBIGUOUS: multiple hosts own this id');
|
|
409
|
+
if (sessions.length)
|
|
410
|
+
return { session: sessions[0] };
|
|
411
|
+
const failure = results.find(result => result.status === 'rejected' && !isConnError(result.reason));
|
|
412
|
+
if (failure?.status === 'rejected')
|
|
413
|
+
throw failure.reason;
|
|
414
|
+
return { session: null };
|
|
349
415
|
}
|
|
350
416
|
function isConnError(error) {
|
|
351
417
|
const code = error?.code;
|
|
@@ -359,9 +425,9 @@ function isUnavailableConnection(error) {
|
|
|
359
425
|
const code = cause?.code;
|
|
360
426
|
return isConnError(cause) || isSocketSandboxError(cause) || code === 'EACCES';
|
|
361
427
|
}
|
|
362
|
-
async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource) {
|
|
363
|
-
const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
|
|
364
|
-
if (
|
|
428
|
+
async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource, socketPath) {
|
|
429
|
+
const configuredSocketPath = socketPath ?? process.env.MEARL_SOCKET_PATH?.trim();
|
|
430
|
+
if (configuredSocketPath && (socketPath || !selector)) {
|
|
365
431
|
try {
|
|
366
432
|
return await invokeOnce(action, data, timeoutSec, transport, configuredSocketPath, controlSource);
|
|
367
433
|
}
|
|
@@ -447,11 +513,9 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
|
|
|
447
513
|
}
|
|
448
514
|
// No explicit target: prefer the most-recently-focused live browser; on a
|
|
449
515
|
// stale socket, fall through to the legacy path + auto-spawn.
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
if (routableBrowsers.length > 0) {
|
|
454
|
-
const target = routableBrowsers[0];
|
|
516
|
+
const candidates = routableBrowsers(browsers, action, data, transport);
|
|
517
|
+
if (candidates.length > 0) {
|
|
518
|
+
const target = candidates[0];
|
|
455
519
|
try {
|
|
456
520
|
return await invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.transport === 'provider' ? target.browserId : undefined);
|
|
457
521
|
}
|
package/dist/unified.d.ts
CHANGED
|
@@ -30,12 +30,8 @@ interface BrowserTarget {
|
|
|
30
30
|
}
|
|
31
31
|
export declare function decodeBrowserTarget(selector: string | undefined): BrowserTarget | undefined;
|
|
32
32
|
export declare function normalizeBrowserSelector(selector: string): string;
|
|
33
|
-
export declare function decodeControlSessionId(id: string): {
|
|
34
|
-
browser: string;
|
|
35
|
-
id: string;
|
|
36
|
-
transport: 'extension' | 'cdp' | 'provider';
|
|
37
|
-
};
|
|
38
33
|
export declare class UnifiedClient {
|
|
34
|
+
private readonly controlSessions;
|
|
39
35
|
private readonly options;
|
|
40
36
|
private defaultBrowser;
|
|
41
37
|
private defaultConnector;
|
|
@@ -55,6 +51,7 @@ export declare class UnifiedClient {
|
|
|
55
51
|
outputPath: string;
|
|
56
52
|
bytes: number;
|
|
57
53
|
}>;
|
|
54
|
+
private pinPageEvalTarget;
|
|
58
55
|
private invokeRouted;
|
|
59
56
|
private invokeExplicitLocal;
|
|
60
57
|
private invokeLaunchWithoutControlBrowser;
|
|
@@ -71,6 +68,7 @@ export declare class UnifiedClient {
|
|
|
71
68
|
private invokeLocal;
|
|
72
69
|
private invokeRemote;
|
|
73
70
|
private decorateActionResult;
|
|
71
|
+
private resolveControlSession;
|
|
74
72
|
private ensureConnected;
|
|
75
73
|
private connect;
|
|
76
74
|
private startHeartbeat;
|
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 =
|
|
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
|
|
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.
|
|
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/
|
|
63
|
-
"@mearl/
|
|
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",
|