@xnng/browser-relay 1.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.
- package/LICENSE +22 -0
- package/README.md +445 -0
- package/docs/README.zh-CN.md +421 -0
- package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
- package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
- package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
- package/docs/benchmarks/browser-readiness-cost.json +106 -0
- package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
- package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
- package/docs/benchmarks/browser-runtime-balanced.json +412 -0
- package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
- package/docs/benchmarks/browser-runtime.json +254 -0
- package/docs/benchmarks/browser-use-parity.json +54 -0
- package/docs/benchmarks/codex-extension-audit.json +131 -0
- package/docs/benchmarks/codex-native-protocol.md +69 -0
- package/docs/benchmarks/codex-native-replay.js +116 -0
- package/docs/benchmarks/codex-native-status.json +81 -0
- package/docs/benchmarks/codex-native.json +1003 -0
- package/docs/benchmarks/extension-sessions.png +0 -0
- package/docs/benchmarks/extension-tasks.png +0 -0
- package/docs/benchmarks/iframe-routing-regression.json +37 -0
- package/docs/browser-use-comparison.md +331 -0
- package/docs/browser-use-gap-audit.md +141 -0
- package/docs/browser-use-parity.md +105 -0
- package/docs/demo/intranet.html +103 -0
- package/docs/releases/v1.5.0.md +59 -0
- package/docs/releases/v1.5.1.md +16 -0
- package/docs/releases/v1.5.2.md +42 -0
- package/docs/releases/v1.5.3.md +33 -0
- package/docs/releases/v1.5.4.md +42 -0
- package/docs/releases/v1.6.0.md +16 -0
- package/docs/remote-control-hub.md +523 -0
- package/extension/activity.js +328 -0
- package/extension/automation.js +1839 -0
- package/extension/background.js +1854 -0
- package/extension/i18n.js +149 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon32.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +47 -0
- package/extension/observations.js +109 -0
- package/extension/options.html +289 -0
- package/extension/options.js +269 -0
- package/extension/popup.html +74 -0
- package/extension/popup.js +105 -0
- package/extension/protocol.js +45 -0
- package/extension/remote-auth.js +18 -0
- package/extension/sessions.js +134 -0
- package/extension/snapshot.js +161 -0
- package/extension/task-groups.js +108 -0
- package/extension/tasks.js +186 -0
- package/extension/wait.js +89 -0
- package/hub/README.md +42 -0
- package/hub/package-lock.json +1544 -0
- package/hub/package.json +13 -0
- package/hub/src/rpc.js +41 -0
- package/hub/src/worker.js +322 -0
- package/hub/wrangler.example.toml +19 -0
- package/package.json +83 -0
- package/server/cdp-bridge.js +200 -0
- package/server/cli.js +1798 -0
- package/server/hub-server.js +258 -0
- package/server/install.js +250 -0
- package/server/mcp-server.js +504 -0
- package/server/npx-runner.js +96 -0
- package/server/relay-server.js +1356 -0
- package/server/remote-protocol.js +76 -0
- package/server/runtime-worker.js +166 -0
- package/server/script-runtime.js +189 -0
- package/server/sdk.js +307 -0
- package/server/service-state.js +103 -0
- package/server/snapshot.js +161 -0
- package/server/uninstall.js +61 -0
- package/server/windows-service-entry.js +58 -0
- package/server/windows-service.js +360 -0
- package/skills/browser-relay/SKILL.md +192 -0
- package/skills/browser-relay/references/legacy-api.md +163 -0
- package/skills/browser-relay/references/runtime.md +240 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM-to-text extraction function.
|
|
3
|
+
* This JS string is sent to the browser via Runtime.evaluate and returns
|
|
4
|
+
* a text representation of the visible page suitable for an LLM.
|
|
5
|
+
*/
|
|
6
|
+
export const SNAPSHOT_JS = `
|
|
7
|
+
(function() {
|
|
8
|
+
const INTERACTIVE_TAGS = new Set([
|
|
9
|
+
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'DETAILS', 'SUMMARY',
|
|
10
|
+
]);
|
|
11
|
+
const SKIP_TAGS = new Set([
|
|
12
|
+
'SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG', 'PATH', 'TEMPLATE',
|
|
13
|
+
'IFRAME', 'OBJECT', 'EMBED',
|
|
14
|
+
]);
|
|
15
|
+
const BLOCK_TAGS = new Set([
|
|
16
|
+
'DIV', 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
|
17
|
+
'UL', 'OL', 'LI', 'TABLE', 'TR', 'SECTION', 'ARTICLE',
|
|
18
|
+
'NAV', 'HEADER', 'FOOTER', 'MAIN', 'ASIDE', 'FORM',
|
|
19
|
+
'BLOCKQUOTE', 'PRE', 'HR', 'BR', 'DL', 'DT', 'DD',
|
|
20
|
+
'FIGURE', 'FIGCAPTION', 'ADDRESS',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function isVisible(el) {
|
|
24
|
+
if (!el.offsetParent && el.tagName !== 'BODY' && el.tagName !== 'HTML') {
|
|
25
|
+
var style = getComputedStyle(el);
|
|
26
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
27
|
+
if (style.display !== 'contents' && style.position !== 'fixed' && style.position !== 'sticky') return false;
|
|
28
|
+
}
|
|
29
|
+
if (el.getAttribute('aria-hidden') === 'true') return false;
|
|
30
|
+
if (el.hasAttribute('hidden')) return false;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function describeElement(el) {
|
|
35
|
+
var tag = el.tagName;
|
|
36
|
+
if (tag === 'A') {
|
|
37
|
+
var href = el.getAttribute('href') || '';
|
|
38
|
+
var text = (el.innerText || el.getAttribute('aria-label') || '').trim();
|
|
39
|
+
return '[link' + (text ? ' "' + text + '"' : '') + (href ? ' href="' + href + '"' : '') + ']';
|
|
40
|
+
}
|
|
41
|
+
if (tag === 'BUTTON' || (el.getAttribute('role') === 'button')) {
|
|
42
|
+
var text = (el.getAttribute('aria-label') || el.innerText || '').trim();
|
|
43
|
+
return '[button' + (text ? ' "' + text + '"' : '') + ']';
|
|
44
|
+
}
|
|
45
|
+
if (tag === 'INPUT') {
|
|
46
|
+
var type = el.getAttribute('type') || 'text';
|
|
47
|
+
var name = el.getAttribute('name') || '';
|
|
48
|
+
var placeholder = el.getAttribute('placeholder') || '';
|
|
49
|
+
var label = el.getAttribute('aria-label') || '';
|
|
50
|
+
var val = type === 'password' ? '[redacted]' : (el.value || '').slice(0, 80);
|
|
51
|
+
var desc = '[input type=' + type;
|
|
52
|
+
if (name) desc += ' name="' + name + '"';
|
|
53
|
+
if (placeholder) desc += ' placeholder="' + placeholder + '"';
|
|
54
|
+
if (label) desc += ' label="' + label + '"';
|
|
55
|
+
if (val) desc += ' value="' + val + '"';
|
|
56
|
+
desc += ']';
|
|
57
|
+
return desc;
|
|
58
|
+
}
|
|
59
|
+
if (tag === 'TEXTAREA') {
|
|
60
|
+
var name = el.getAttribute('name') || '';
|
|
61
|
+
var placeholder = el.getAttribute('placeholder') || '';
|
|
62
|
+
var val = (el.value || '').slice(0, 200);
|
|
63
|
+
var desc = '[textarea';
|
|
64
|
+
if (name) desc += ' name="' + name + '"';
|
|
65
|
+
if (placeholder) desc += ' placeholder="' + placeholder + '"';
|
|
66
|
+
if (val) desc += ' value="' + val + '"';
|
|
67
|
+
desc += ']';
|
|
68
|
+
return desc;
|
|
69
|
+
}
|
|
70
|
+
if (tag === 'SELECT') {
|
|
71
|
+
var name = el.getAttribute('name') || '';
|
|
72
|
+
var selected = el.options && el.selectedIndex >= 0 ? (el.options[el.selectedIndex].text || '').slice(0, 60) : '';
|
|
73
|
+
var desc = '[select';
|
|
74
|
+
if (name) desc += ' name="' + name + '"';
|
|
75
|
+
if (selected) desc += ' selected="' + selected + '"';
|
|
76
|
+
desc += ']';
|
|
77
|
+
return desc;
|
|
78
|
+
}
|
|
79
|
+
if (tag === 'IMG') {
|
|
80
|
+
var alt = el.getAttribute('alt') || '';
|
|
81
|
+
return alt ? '[image "' + alt.slice(0, 100) + '"]' : '[image]';
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var lines = [];
|
|
87
|
+
var maxLen = (typeof __maxLength !== 'undefined') ? __maxLength : 100000;
|
|
88
|
+
var totalLen = 0;
|
|
89
|
+
var truncated = false;
|
|
90
|
+
|
|
91
|
+
function addLine(text, indent) {
|
|
92
|
+
if (truncated) return;
|
|
93
|
+
var line = ' '.repeat(indent) + text;
|
|
94
|
+
totalLen += line.length + 1;
|
|
95
|
+
if (totalLen > maxLen) { truncated = true; return; }
|
|
96
|
+
lines.push(line);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function walk(node, depth) {
|
|
100
|
+
if (truncated) return;
|
|
101
|
+
if (node.nodeType === 3) {
|
|
102
|
+
var text = node.textContent.trim();
|
|
103
|
+
if (text) addLine(text, depth);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (node.nodeType !== 1) return;
|
|
107
|
+
var el = node;
|
|
108
|
+
var tag = el.tagName;
|
|
109
|
+
if (SKIP_TAGS.has(tag)) return;
|
|
110
|
+
if (!isVisible(el)) return;
|
|
111
|
+
|
|
112
|
+
var headingMatch = tag.match(/^H([1-6])$/);
|
|
113
|
+
if (headingMatch) {
|
|
114
|
+
var text = (el.innerText || '').trim();
|
|
115
|
+
if (text) addLine('#'.repeat(parseInt(headingMatch[1])) + ' ' + text, depth);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
var desc = describeElement(el);
|
|
120
|
+
if (desc) {
|
|
121
|
+
addLine(desc, depth);
|
|
122
|
+
if (tag === 'A' || tag === 'BUTTON') return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
var isBlock = BLOCK_TAGS.has(tag);
|
|
126
|
+
var childDepth = desc ? depth + 1 : (isBlock ? depth : depth);
|
|
127
|
+
|
|
128
|
+
if (tag === 'LI') {
|
|
129
|
+
var text = '';
|
|
130
|
+
for (var i = 0; i < el.childNodes.length; i++) {
|
|
131
|
+
var child = el.childNodes[i];
|
|
132
|
+
if (child.nodeType === 3) text += child.textContent;
|
|
133
|
+
}
|
|
134
|
+
text = text.trim();
|
|
135
|
+
if (text) addLine('- ' + text, depth);
|
|
136
|
+
for (var i = 0; i < el.children.length; i++) {
|
|
137
|
+
walk(el.children[i], depth + 1);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (tag === 'TR') {
|
|
143
|
+
var cells = [];
|
|
144
|
+
for (var i = 0; i < el.children.length; i++) {
|
|
145
|
+
cells.push((el.children[i].innerText || '').trim());
|
|
146
|
+
}
|
|
147
|
+
if (cells.some(function(c) { return c; })) {
|
|
148
|
+
addLine('| ' + cells.join(' | ') + ' |', depth);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (var i = 0; i < el.childNodes.length; i++) {
|
|
154
|
+
walk(el.childNodes[i], childDepth);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
walk(document.body, 0);
|
|
159
|
+
return JSON.stringify({ snapshot: lines.join('\\n'), truncated: truncated });
|
|
160
|
+
})()
|
|
161
|
+
`;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { TaskError } from './tasks.js';
|
|
2
|
+
|
|
3
|
+
// 页面资源独立于两分钟的控制租约,断线/租约过期不能代表用户任务完成。
|
|
4
|
+
// storage.session 允许 service worker 重启后清理,浏览器重启后自动失效,避免复用旧 tab/group ID。
|
|
5
|
+
export function createTaskGroups(chrome) {
|
|
6
|
+
const key = 'automationTaskGroupsV1';
|
|
7
|
+
let names = new Map(), records = new Map();
|
|
8
|
+
const ready = chrome.storage.session.get(key).then(data => {
|
|
9
|
+
names = new Map(data[key]?.names || []);
|
|
10
|
+
records = new Map(data[key]?.records || []);
|
|
11
|
+
});
|
|
12
|
+
const save = () => chrome.storage.session.set({ [key]: { names: [...names], records: [...records] } });
|
|
13
|
+
const valid = id => {
|
|
14
|
+
if (typeof id !== 'string' || !/^[\w.-]{1,128}$/.test(id))
|
|
15
|
+
throw new TaskError('invalid_session', 'A named session is required');
|
|
16
|
+
};
|
|
17
|
+
async function current(id) {
|
|
18
|
+
try { return await chrome.tabs.get(id); }
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (/No tab with id|Invalid tab ID/i.test(error.message)) return null;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function list() {
|
|
25
|
+
await ready;
|
|
26
|
+
const result = new Map();
|
|
27
|
+
for (const [tabId, record] of records) {
|
|
28
|
+
const tab = await current(tabId);
|
|
29
|
+
if (!tab || tab.groupId !== record.groupId) { records.delete(tabId); continue; }
|
|
30
|
+
if (!result.has(record.sessionId)) result.set(record.sessionId, {
|
|
31
|
+
sessionId: record.sessionId, label: names.get(record.sessionId) || record.sessionId, groups: [],
|
|
32
|
+
});
|
|
33
|
+
const item = result.get(record.sessionId);
|
|
34
|
+
let group = item.groups.find(g => g.groupId === tab.groupId);
|
|
35
|
+
if (!group) {
|
|
36
|
+
const details = await chrome.tabGroups.get(tab.groupId);
|
|
37
|
+
item.groups.push(group = { groupId: tab.groupId, windowId: tab.windowId, title: details.title, tabIds: [] });
|
|
38
|
+
}
|
|
39
|
+
group.tabIds.push(tabId);
|
|
40
|
+
}
|
|
41
|
+
await save();
|
|
42
|
+
return [...result.values()];
|
|
43
|
+
}
|
|
44
|
+
async function start(id, label) {
|
|
45
|
+
valid(id); await ready;
|
|
46
|
+
if (label !== undefined && (typeof label !== 'string' || !label.trim() || label.length > 100))
|
|
47
|
+
throw new TaskError('invalid_label', 'Task label must contain 1–100 characters');
|
|
48
|
+
names.set(id, label || names.get(id) || id);
|
|
49
|
+
for (const item of await list()) if (item.sessionId === id)
|
|
50
|
+
for (const group of item.groups) await chrome.tabGroups.update(group.groupId, { title: names.get(id) });
|
|
51
|
+
await save();
|
|
52
|
+
}
|
|
53
|
+
async function add(tabId, id) {
|
|
54
|
+
valid(id); await ready;
|
|
55
|
+
const tab = await chrome.tabs.get(tabId);
|
|
56
|
+
const tasks = await list();
|
|
57
|
+
const existing = tasks.find(s => s.sessionId === id)?.groups.find(g => g.windowId === tab.windowId);
|
|
58
|
+
const groupId = await chrome.tabs.group({ tabIds: [tabId], ...(existing ? { groupId: existing.groupId } : {}) });
|
|
59
|
+
// 先保存创建来源;命名失败仍能识别并清理这一页,不把组内其他用户页面当成任务页面。
|
|
60
|
+
records.set(tabId, { sessionId: id, groupId });
|
|
61
|
+
await save();
|
|
62
|
+
await chrome.tabGroups.update(groupId, { title: names.get(id) || id, color: 'blue' });
|
|
63
|
+
return groupId;
|
|
64
|
+
}
|
|
65
|
+
async function claim(tabId, id) {
|
|
66
|
+
await ready;
|
|
67
|
+
const record = records.get(tabId);
|
|
68
|
+
if (!record) return false;
|
|
69
|
+
const tab = await current(tabId);
|
|
70
|
+
if (!tab || tab.groupId !== record.groupId) {
|
|
71
|
+
records.delete(tabId); await save(); return false;
|
|
72
|
+
}
|
|
73
|
+
// 新任务接管已释放租约的任务页时,资源归属也一起转移,旧任务不能在稍后误关。
|
|
74
|
+
if (record.sessionId !== id) await add(tabId, id);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
async function release(tabId, id) {
|
|
78
|
+
await ready;
|
|
79
|
+
const record = records.get(tabId);
|
|
80
|
+
if (record?.sessionId !== id) return;
|
|
81
|
+
const tab = await current(tabId);
|
|
82
|
+
if (tab?.groupId === record.groupId) await chrome.tabs.ungroup([tabId]);
|
|
83
|
+
records.delete(tabId); await save();
|
|
84
|
+
}
|
|
85
|
+
async function complete(id, ownerOf, publicId = id => id) {
|
|
86
|
+
valid(id); await ready;
|
|
87
|
+
const closed = [], preserved = [], errors = [];
|
|
88
|
+
for (const [tabId, record] of records) {
|
|
89
|
+
if (record.sessionId !== id) continue;
|
|
90
|
+
const resultId = publicId(tabId);
|
|
91
|
+
try {
|
|
92
|
+
const tab = await current(tabId);
|
|
93
|
+
const owner = ownerOf(tabId);
|
|
94
|
+
// 手动移出/改组、交给其他 session 的页面视为用户接管,不再自动关闭。
|
|
95
|
+
if (tab && tab.groupId === record.groupId && (!owner || owner === id)) {
|
|
96
|
+
await chrome.tabs.remove(tabId);
|
|
97
|
+
closed.push(resultId);
|
|
98
|
+
} else if (tab) preserved.push(resultId);
|
|
99
|
+
records.delete(tabId);
|
|
100
|
+
await save();
|
|
101
|
+
} catch (error) { errors.push({ tabId: resultId, message: error.message }); }
|
|
102
|
+
}
|
|
103
|
+
if (!errors.length) names.delete(id);
|
|
104
|
+
await save();
|
|
105
|
+
return { closed, preserved, errors };
|
|
106
|
+
}
|
|
107
|
+
return { start, add, claim, release, complete, list };
|
|
108
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Shared by the extension and tests. A queue owns a whole operation, not one
|
|
2
|
+
// mouse event, so independently submitted scripts cannot interleave keystrokes.
|
|
3
|
+
export class TaskError extends Error {
|
|
4
|
+
constructor(code, message, status = 400) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function checkCancelled(signal) {
|
|
11
|
+
if (signal?.aborted)
|
|
12
|
+
throw new TaskError(
|
|
13
|
+
"task_cancelled",
|
|
14
|
+
"Task cancelled; completed actions were not undone",
|
|
15
|
+
409,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
export function pause(ms, signal) {
|
|
19
|
+
checkCancelled(signal);
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const abort = () => {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
reject(new TaskError("task_cancelled", "Task cancelled", 409));
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
signal?.removeEventListener("abort", abort);
|
|
27
|
+
resolve();
|
|
28
|
+
}, ms);
|
|
29
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function createTaskQueue({ limit = 100 } = {}) {
|
|
33
|
+
const tails = new Map(),
|
|
34
|
+
jobs = new Map(),
|
|
35
|
+
cancelledRequests = new Map();
|
|
36
|
+
const publicJob = (job) => ({
|
|
37
|
+
id: job.id,
|
|
38
|
+
tabId: job.tabId,
|
|
39
|
+
sessionId: job.sessionId,
|
|
40
|
+
status: job.status,
|
|
41
|
+
createdAt: job.createdAt,
|
|
42
|
+
startedAt: job.startedAt,
|
|
43
|
+
finishedAt: job.finishedAt,
|
|
44
|
+
results: job.results,
|
|
45
|
+
completedActions: job.results.length,
|
|
46
|
+
observation: job.observation,
|
|
47
|
+
error: job.error,
|
|
48
|
+
interruptedAction:
|
|
49
|
+
job.finishedAt && job.currentAction ? job.currentAction : undefined,
|
|
50
|
+
});
|
|
51
|
+
function get(id) {
|
|
52
|
+
const job = jobs.get(id);
|
|
53
|
+
if (!job && cancelledRequests.has(id)) return cancelledRequests.get(id);
|
|
54
|
+
if (!job)
|
|
55
|
+
throw new TaskError(
|
|
56
|
+
"task_not_found",
|
|
57
|
+
"Task not found (the extension may have restarted)",
|
|
58
|
+
404,
|
|
59
|
+
);
|
|
60
|
+
return publicJob(job);
|
|
61
|
+
}
|
|
62
|
+
function start(tabId, run, timeoutMs = 20000, requestedId, sessionId) {
|
|
63
|
+
for (const [id, job] of jobs) {
|
|
64
|
+
if (jobs.size < limit) break;
|
|
65
|
+
if (job.finishedAt) jobs.delete(id);
|
|
66
|
+
}
|
|
67
|
+
if (jobs.size >= limit)
|
|
68
|
+
throw new TaskError("task_limit", "Too many pending tasks", 429);
|
|
69
|
+
const id = requestedId || `job_${crypto.randomUUID()}`;
|
|
70
|
+
if (!/^job_[\w-]{36}$/.test(id))
|
|
71
|
+
throw new TaskError("invalid_task_id", "Invalid task id");
|
|
72
|
+
if (cancelledRequests.has(id))
|
|
73
|
+
throw new TaskError(
|
|
74
|
+
"task_cancelled",
|
|
75
|
+
"This request was cancelled before execution",
|
|
76
|
+
409,
|
|
77
|
+
);
|
|
78
|
+
if (jobs.has(id))
|
|
79
|
+
throw new TaskError(
|
|
80
|
+
"duplicate_task",
|
|
81
|
+
"Task id already exists; inspect its result instead of replaying actions",
|
|
82
|
+
409,
|
|
83
|
+
);
|
|
84
|
+
const job = {
|
|
85
|
+
id,
|
|
86
|
+
tabId,
|
|
87
|
+
sessionId,
|
|
88
|
+
status: "queued",
|
|
89
|
+
createdAt: Date.now(),
|
|
90
|
+
results: [],
|
|
91
|
+
controller: new AbortController(),
|
|
92
|
+
};
|
|
93
|
+
jobs.set(id, job);
|
|
94
|
+
const previous = tails.get(tabId) || Promise.resolve();
|
|
95
|
+
const done = previous
|
|
96
|
+
.catch(() => {})
|
|
97
|
+
.then(async () => {
|
|
98
|
+
let timer;
|
|
99
|
+
try {
|
|
100
|
+
checkCancelled(job.controller.signal);
|
|
101
|
+
job.status = "running";
|
|
102
|
+
job.startedAt = Date.now();
|
|
103
|
+
timer = setTimeout(() => {
|
|
104
|
+
job.timedOut = true;
|
|
105
|
+
job.controller.abort();
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
const observation = await run(job, job.controller.signal);
|
|
108
|
+
checkCancelled(job.controller.signal);
|
|
109
|
+
job.observation = observation;
|
|
110
|
+
job.status = "completed";
|
|
111
|
+
} catch (error) {
|
|
112
|
+
job.status = job.controller.signal.aborted ? "cancelled" : "failed";
|
|
113
|
+
job.error = {
|
|
114
|
+
code: job.timedOut
|
|
115
|
+
? "task_timeout"
|
|
116
|
+
: job.cancelReason || error.code || "action_failed",
|
|
117
|
+
message: error.message,
|
|
118
|
+
status: error.status || 500,
|
|
119
|
+
};
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
job.finishedAt = Date.now();
|
|
123
|
+
}
|
|
124
|
+
return publicJob(job);
|
|
125
|
+
});
|
|
126
|
+
tails.set(tabId, done);
|
|
127
|
+
done.finally(() => {
|
|
128
|
+
if (tails.get(tabId) === done) tails.delete(tabId);
|
|
129
|
+
});
|
|
130
|
+
return { id, done };
|
|
131
|
+
}
|
|
132
|
+
function cancel(id, reason = "task_cancelled", sessionId) {
|
|
133
|
+
if (!jobs.has(id)) {
|
|
134
|
+
if (!/^job_[\w-]{36}$/.test(id))
|
|
135
|
+
throw new TaskError("invalid_task_id", "Invalid task id");
|
|
136
|
+
const result = {
|
|
137
|
+
id,
|
|
138
|
+
sessionId,
|
|
139
|
+
status: "cancelled",
|
|
140
|
+
results: [],
|
|
141
|
+
error: {
|
|
142
|
+
code: reason,
|
|
143
|
+
message: "Request cancelled before execution",
|
|
144
|
+
status: 409,
|
|
145
|
+
},
|
|
146
|
+
cancellationRequested: true,
|
|
147
|
+
};
|
|
148
|
+
cancelledRequests.set(id, result);
|
|
149
|
+
if (cancelledRequests.size > 500)
|
|
150
|
+
cancelledRequests.delete(cancelledRequests.keys().next().value);
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
get(id);
|
|
154
|
+
const job = jobs.get(id);
|
|
155
|
+
if (!job.finishedAt) {
|
|
156
|
+
job.cancelReason = reason;
|
|
157
|
+
job.controller.abort();
|
|
158
|
+
}
|
|
159
|
+
return { ...publicJob(job), cancellationRequested: !job.finishedAt };
|
|
160
|
+
}
|
|
161
|
+
function cancelTab(tabId, reason = "task_cancelled") {
|
|
162
|
+
for (const job of jobs.values())
|
|
163
|
+
if (job.tabId === tabId && !job.finishedAt) cancel(job.id, reason);
|
|
164
|
+
}
|
|
165
|
+
function cancelSession(sessionId, reason = "session_stopped") {
|
|
166
|
+
for (const job of jobs.values())
|
|
167
|
+
if (job.sessionId === sessionId && !job.finishedAt)
|
|
168
|
+
cancel(job.id, reason);
|
|
169
|
+
}
|
|
170
|
+
const active = () =>
|
|
171
|
+
[...jobs.values()]
|
|
172
|
+
.filter((j) => !j.finishedAt)
|
|
173
|
+
.map((j) => ({
|
|
174
|
+
id: j.id,
|
|
175
|
+
tabId: j.tabId,
|
|
176
|
+
sessionId: j.sessionId,
|
|
177
|
+
status: j.status,
|
|
178
|
+
completedActions: j.results.length,
|
|
179
|
+
}));
|
|
180
|
+
const cancelAll = () => {
|
|
181
|
+
const pending = active();
|
|
182
|
+
for (const job of pending) cancel(job.id);
|
|
183
|
+
return pending.length;
|
|
184
|
+
};
|
|
185
|
+
return { start, get, cancel, cancelTab, cancelSession, active, cancelAll };
|
|
186
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const WAIT_STATES = ["attached", "visible"];
|
|
2
|
+
export const WAIT_DEFAULT_TIMEOUT_MS = 5_000;
|
|
3
|
+
export const WAIT_DEFAULT_POLL_MS = 100;
|
|
4
|
+
export const WAIT_MIN_TIMEOUT_MS = 1;
|
|
5
|
+
export const WAIT_MAX_TIMEOUT_MS = 20_000;
|
|
6
|
+
export const WAIT_MIN_POLL_MS = 50;
|
|
7
|
+
export const WAIT_MAX_POLL_MS = 1_000;
|
|
8
|
+
|
|
9
|
+
function boundedInteger(value, fallback, min, max, field) {
|
|
10
|
+
if (value === undefined) return { ok: true, value: fallback };
|
|
11
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
12
|
+
return { ok: false, field, message: `${field} must be an integer between ${min} and ${max}` };
|
|
13
|
+
}
|
|
14
|
+
return { ok: true, value };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeWaitOptions(input = {}) {
|
|
18
|
+
const selector = input.selector;
|
|
19
|
+
if (typeof selector !== "string" || !selector.trim()) {
|
|
20
|
+
return { ok: false, field: "selector", message: "selector is required" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const state = input.state ?? "visible";
|
|
24
|
+
if (!WAIT_STATES.includes(state)) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
field: "state",
|
|
28
|
+
message: `state must be one of: ${WAIT_STATES.join(", ")}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const timeout = boundedInteger(
|
|
33
|
+
input.timeoutMs,
|
|
34
|
+
WAIT_DEFAULT_TIMEOUT_MS,
|
|
35
|
+
WAIT_MIN_TIMEOUT_MS,
|
|
36
|
+
WAIT_MAX_TIMEOUT_MS,
|
|
37
|
+
"timeoutMs",
|
|
38
|
+
);
|
|
39
|
+
if (!timeout.ok) return timeout;
|
|
40
|
+
|
|
41
|
+
const poll = boundedInteger(
|
|
42
|
+
input.pollMs,
|
|
43
|
+
WAIT_DEFAULT_POLL_MS,
|
|
44
|
+
WAIT_MIN_POLL_MS,
|
|
45
|
+
WAIT_MAX_POLL_MS,
|
|
46
|
+
"pollMs",
|
|
47
|
+
);
|
|
48
|
+
if (!poll.ok) return poll;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
selector,
|
|
53
|
+
state,
|
|
54
|
+
timeoutMs: timeout.value,
|
|
55
|
+
pollMs: poll.value,
|
|
56
|
+
tabId: input.tabId,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildWaitExpression(selector, state) {
|
|
61
|
+
return `(() => {
|
|
62
|
+
const __browserRelayWait = true;
|
|
63
|
+
try {
|
|
64
|
+
const nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)}));
|
|
65
|
+
const isVisible = (element) => {
|
|
66
|
+
if (!element || !element.isConnected) return false;
|
|
67
|
+
const rect = element.getBoundingClientRect();
|
|
68
|
+
if (!(rect.width > 0 || rect.height > 0 || element.getClientRects().length > 0)) return false;
|
|
69
|
+
for (let node = element; node && node.nodeType === 1; node = node.parentElement) {
|
|
70
|
+
const style = getComputedStyle(node);
|
|
71
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || Number.parseFloat(style.opacity) === 0) return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
const visibleCount = nodes.filter(isVisible).length;
|
|
76
|
+
return {
|
|
77
|
+
matched: ${JSON.stringify(state)} === 'attached' ? nodes.length > 0 : visibleCount > 0,
|
|
78
|
+
matchCount: nodes.length,
|
|
79
|
+
visibleCount,
|
|
80
|
+
};
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return {
|
|
83
|
+
matched: false,
|
|
84
|
+
invalidSelector: true,
|
|
85
|
+
message: error instanceof Error ? error.message : String(error),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
})()`;
|
|
89
|
+
}
|
package/hub/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Browser Relay Hub on Cloudflare
|
|
2
|
+
|
|
3
|
+
The public relay is a small Cloudflare Worker + Durable Object. Run your own so remote control never touches a third-party service.
|
|
4
|
+
|
|
5
|
+
## Deploy your own
|
|
6
|
+
|
|
7
|
+
One click — deploys this Worker to your own Cloudflare account:
|
|
8
|
+
|
|
9
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/reliefeai/browser-relay/tree/main/hub)
|
|
10
|
+
|
|
11
|
+
The button clones this repo into your account and deploys via Workers Builds, so the first time it asks you to connect Cloudflare to GitHub. If you hit `no associated Git installation`, reconnect Git in the Cloudflare dashboard (**Workers & Pages → connect Git**) and make sure this repo is in the allowed list, then click again.
|
|
12
|
+
|
|
13
|
+
Prefer the command line?
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/reliefeai/browser-relay
|
|
17
|
+
cd browser-relay/hub && npx wrangler deploy
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Either way you get a URL like `https://browser-relay-hub.<your-subdomain>.workers.dev`. Put it in the extension's **Public relay** field (Options → Remote Relay). No custom domain required; to use your own, add a `routes` entry to `wrangler.toml`.
|
|
21
|
+
|
|
22
|
+
## Smoke check
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
curl https://browser-relay-hub.<your-subdomain>.workers.dev/v1/health
|
|
26
|
+
# { "ok": true, "service": "browser-relay-hub" }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Endpoints
|
|
30
|
+
|
|
31
|
+
- `GET /v1/device/connect?routeId=...` — the browser extension's outbound WebSocket. The device sends its secret in the first `device.auth` frame, never in the URL.
|
|
32
|
+
- `POST /v1/rpc` — remote CLI/MCP command entrypoint.
|
|
33
|
+
- `GET /v1/status/:routeId` — device online status.
|
|
34
|
+
|
|
35
|
+
The `remote-device-id` is a capability secret generated by the extension. The hub keeps only an in-memory hash of it while the device is connected; nothing is persisted.
|
|
36
|
+
|
|
37
|
+
For rollout compatibility, the Hub temporarily accepts older extensions that send
|
|
38
|
+
`?token=...`. New extension code intentionally has no query-token fallback. Deploy the
|
|
39
|
+
new Hub before updating extensions, then remove legacy acceptance after old clients have
|
|
40
|
+
aged out.
|
|
41
|
+
|
|
42
|
+
For the npm distribution, copy `wrangler.example.toml` to `wrangler.toml` before deploying. The package excludes machine-specific deployment configuration and local dependencies.
|