@web-auto/camo 0.2.0 → 0.2.2

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.
Files changed (114) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +586 -586
  3. package/bin/browser-service.mjs +11 -11
  4. package/bin/camo.mjs +22 -22
  5. package/package.json +48 -48
  6. package/scripts/build.mjs +19 -19
  7. package/scripts/bump-version.mjs +34 -34
  8. package/scripts/check-file-size.mjs +80 -80
  9. package/scripts/file-size-policy.json +12 -2
  10. package/scripts/install.mjs +76 -76
  11. package/scripts/release.sh +54 -54
  12. package/src/autoscript/action-providers/index.mjs +6 -6
  13. package/src/autoscript/impact-engine.mjs +78 -78
  14. package/src/autoscript/runtime.mjs +1017 -1017
  15. package/src/autoscript/schema.mjs +376 -376
  16. package/src/cli.mjs +405 -405
  17. package/src/commands/attach.mjs +141 -141
  18. package/src/commands/autoscript.mjs +1011 -1011
  19. package/src/commands/browser.mjs +1255 -1255
  20. package/src/commands/container.mjs +401 -401
  21. package/src/commands/cookies.mjs +69 -69
  22. package/src/commands/create.mjs +98 -98
  23. package/src/commands/devtools.mjs +349 -349
  24. package/src/commands/events.mjs +152 -152
  25. package/src/commands/highlight-mode.mjs +24 -24
  26. package/src/commands/init.mjs +68 -68
  27. package/src/commands/lifecycle.mjs +275 -275
  28. package/src/commands/mouse.mjs +45 -45
  29. package/src/commands/profile.mjs +46 -46
  30. package/src/commands/record.mjs +115 -115
  31. package/src/commands/system.mjs +14 -14
  32. package/src/commands/window.mjs +123 -123
  33. package/src/container/change-notifier.mjs +362 -362
  34. package/src/container/element-filter.mjs +143 -143
  35. package/src/container/index.mjs +3 -3
  36. package/src/container/runtime-core/checkpoint.mjs +209 -209
  37. package/src/container/runtime-core/index.mjs +21 -21
  38. package/src/container/runtime-core/operations/index.mjs +774 -774
  39. package/src/container/runtime-core/operations/selector-scripts.mjs +277 -277
  40. package/src/container/runtime-core/operations/tab-pool.mjs +746 -746
  41. package/src/container/runtime-core/operations/viewport.mjs +189 -189
  42. package/src/container/runtime-core/search.mjs +190 -190
  43. package/src/container/runtime-core/subscription.mjs +224 -224
  44. package/src/container/runtime-core/utils.mjs +94 -94
  45. package/src/container/runtime-core/validation.mjs +127 -127
  46. package/src/container/runtime-core.mjs +1 -1
  47. package/src/container/subscription-registry.mjs +459 -459
  48. package/src/core/actions.mjs +561 -561
  49. package/src/core/browser.mjs +266 -266
  50. package/src/core/index.mjs +52 -52
  51. package/src/core/utils.mjs +91 -91
  52. package/src/events/daemon-entry.mjs +33 -33
  53. package/src/events/daemon.mjs +80 -80
  54. package/src/events/progress-log.mjs +109 -109
  55. package/src/events/ws-server.mjs +239 -239
  56. package/src/lib/client.mjs +200 -200
  57. package/src/lifecycle/cleanup.mjs +83 -83
  58. package/src/lifecycle/lock.mjs +126 -126
  59. package/src/lifecycle/session-registry.mjs +279 -279
  60. package/src/lifecycle/session-view.mjs +76 -76
  61. package/src/lifecycle/session-watchdog.mjs +281 -281
  62. package/src/services/browser-service/index.js +671 -671
  63. package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
  64. package/src/services/browser-service/internal/BrowserSession.js +325 -304
  65. package/src/services/browser-service/internal/ElementRegistry.js +60 -60
  66. package/src/services/browser-service/internal/ProfileLock.js +84 -84
  67. package/src/services/browser-service/internal/SessionManager.js +184 -184
  68. package/src/services/browser-service/internal/SessionManager.test.js +39 -39
  69. package/src/services/browser-service/internal/browser-session/cookies.js +144 -144
  70. package/src/services/browser-service/internal/browser-session/input-ops.js +222 -222
  71. package/src/services/browser-service/internal/browser-session/input-pipeline.js +144 -144
  72. package/src/services/browser-service/internal/browser-session/logging.js +46 -46
  73. package/src/services/browser-service/internal/browser-session/navigation.js +38 -38
  74. package/src/services/browser-service/internal/browser-session/page-hooks.js +442 -442
  75. package/src/services/browser-service/internal/browser-session/page-management.js +302 -302
  76. package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -148
  77. package/src/services/browser-service/internal/browser-session/recording.js +198 -198
  78. package/src/services/browser-service/internal/browser-session/runtime-events.js +61 -61
  79. package/src/services/browser-service/internal/browser-session/session-core.js +84 -84
  80. package/src/services/browser-service/internal/browser-session/session-state.js +38 -38
  81. package/src/services/browser-service/internal/browser-session/types.js +14 -14
  82. package/src/services/browser-service/internal/browser-session/utils.js +95 -95
  83. package/src/services/browser-service/internal/browser-session/viewport-manager.js +46 -46
  84. package/src/services/browser-service/internal/browser-session/viewport.js +215 -215
  85. package/src/services/browser-service/internal/container-matcher.js +851 -851
  86. package/src/services/browser-service/internal/container-registry.js +182 -182
  87. package/src/services/browser-service/internal/engine-manager.js +259 -259
  88. package/src/services/browser-service/internal/fingerprint.js +203 -203
  89. package/src/services/browser-service/internal/heartbeat.js +137 -137
  90. package/src/services/browser-service/internal/logging.js +46 -46
  91. package/src/services/browser-service/internal/page-runtime/runtime.js +1317 -1317
  92. package/src/services/browser-service/internal/pageRuntime.js +28 -28
  93. package/src/services/browser-service/internal/runtimeInjector.js +31 -31
  94. package/src/services/browser-service/internal/service-process-logger.js +140 -140
  95. package/src/services/browser-service/internal/state-bus.js +45 -45
  96. package/src/services/browser-service/internal/storage-paths.js +42 -42
  97. package/src/services/browser-service/internal/ws-server.js +1194 -1194
  98. package/src/services/browser-service/internal/ws-server.test.js +58 -58
  99. package/src/services/browser-service/server.mjs +6 -6
  100. package/src/services/controller/cli-bridge.js +93 -93
  101. package/src/services/controller/container-index.js +50 -50
  102. package/src/services/controller/container-storage.js +36 -36
  103. package/src/services/controller/controller-actions.js +207 -207
  104. package/src/services/controller/controller.js +1138 -1138
  105. package/src/services/controller/selectors.js +54 -54
  106. package/src/services/controller/transport.js +125 -125
  107. package/src/utils/args.mjs +26 -26
  108. package/src/utils/browser-service.mjs +544 -544
  109. package/src/utils/command-log.mjs +64 -64
  110. package/src/utils/config.mjs +214 -214
  111. package/src/utils/fingerprint.mjs +181 -181
  112. package/src/utils/help.mjs +216 -216
  113. package/src/utils/js-policy.mjs +13 -13
  114. package/src/utils/ws-client.mjs +30 -30
@@ -1,1194 +1,1194 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
- import { WebSocketServer } from 'ws';
5
- import { SESSION_CLOSED_EVENT } from './SessionManager.js';
6
- import { ContainerMatcher } from './container-matcher.js';
7
- import { ensurePageRuntime } from './pageRuntime.js';
8
- import { logDebug } from './logging.js';
9
- import { CONFIG_DIR } from '../../../utils/config.mjs';
10
- const logsDir = path.join(CONFIG_DIR || path.join(os.homedir(), '.camo'), 'logs');
11
- const domPickerLogPath = path.join(logsDir, 'dom-picker-debug.log');
12
- const highlightLogPath = path.join(logsDir, 'highlight-debug.log');
13
- function appendLog(target, event, payload = {}) {
14
- try {
15
- fs.mkdirSync(path.dirname(target), { recursive: true });
16
- const line = JSON.stringify({
17
- ts: new Date().toISOString(),
18
- event,
19
- ...payload,
20
- });
21
- fs.appendFileSync(target, `${line}\n`, 'utf-8');
22
- }
23
- catch {
24
- /* ignore log errors */
25
- }
26
- }
27
- const appendDomPickerLog = (event, payload = {}) => appendLog(domPickerLogPath, event, payload);
28
- const appendHighlightLog = (event, payload = {}) => appendLog(highlightLogPath, event, payload);
29
- function legacySelectorActionDisabled(source, action) {
30
- return {
31
- success: false,
32
- code: 'LEGACY_ACTION_DISABLED',
33
- error: `[${source}] legacy selector action "${action}" is disabled; use mouse:* and keyboard:* protocol actions instead`,
34
- data: {
35
- source,
36
- action,
37
- replacements: ['mouse:move', 'mouse:click', 'mouse:wheel', 'keyboard:press', 'keyboard:type'],
38
- },
39
- };
40
- }
41
- export class BrowserWsServer {
42
- options;
43
- wss;
44
- matcher = new ContainerMatcher();
45
- capabilityMap = new Map();
46
- subscriptions = new Map();
47
- sessionSubscribers = new Map();
48
- runtimeBridgeUnsub = new Map();
49
- constructor(options) {
50
- this.options = options;
51
- process.on(SESSION_CLOSED_EVENT, (sessionId) => {
52
- this.teardownRuntimeEventBridge(sessionId);
53
- });
54
- }
55
- async start() {
56
- if (this.wss)
57
- return;
58
- const host = this.options.host || '127.0.0.1';
59
- const port = Number(this.options.port || 8765);
60
- this.wss = new WebSocketServer({ host, port });
61
- this.wss.on('connection', (socket) => {
62
- this.subscriptions.set(socket, new Set());
63
- socket.on('message', (data) => this.handleMessage(socket, data));
64
- socket.on('close', () => {
65
- this.handleSocketClose(socket);
66
- });
67
- });
68
- this.wss.on('listening', () => {
69
- console.log(`[browser-ws] listening on ws://${host}:${port}`);
70
- });
71
- this.wss.on('error', (err) => {
72
- console.error('[browser-ws] server error:', err);
73
- });
74
- }
75
- async handleDomPick(session, parameters) {
76
- const timeoutMs = Math.min(Math.max(Number(parameters?.timeout) || 25000, 3000), 60000);
77
- const page = await session.ensurePage();
78
- const sessionId = session?.id || 'unknown';
79
- appendDomPickerLog('start', { sessionId, timeoutMs });
80
- await ensurePageRuntime(page);
81
- try {
82
- await page.bringToFront();
83
- }
84
- catch {
85
- /* ignore */
86
- }
87
- const hasRuntime = await page.evaluate(() => {
88
- const w = window;
89
- return Boolean(w.__domPicker && typeof w.__domPicker.startSession === 'function' && typeof w.__domPicker.getLastState === 'function');
90
- });
91
- if (!hasRuntime) {
92
- appendDomPickerLog('runtime-missing', { sessionId });
93
- return {
94
- success: false,
95
- error: 'domPicker runtime unavailable',
96
- cancelled: false,
97
- timeout: false,
98
- };
99
- }
100
- const rootSelector = parameters.root_selector || parameters.rootSelector || null;
101
- await page.evaluate((opts) => {
102
- const w = window;
103
- try {
104
- w.__domPicker.startSession({ mode: 'hover-select', timeoutMs: opts.timeoutMs, rootSelector: opts.rootSelector });
105
- }
106
- catch (err) {
107
- // swallow, polling below will observe error/idle state
108
- // eslint-disable-next-line no-console
109
- console.warn('[dom-picker] startSession error', err);
110
- }
111
- }, { timeoutMs, rootSelector });
112
- const startedState = await page.evaluate(() => {
113
- const w = window;
114
- return w.__domPicker ? w.__domPicker.getLastState() : null;
115
- });
116
- appendDomPickerLog('started', { sessionId, state: startedState });
117
- const startedAt = Date.now();
118
- const hardTimeout = timeoutMs + 2000;
119
- // Poll state until selected / cancelled / timeout
120
- // We keep polling even after logical timeoutMs so that page-side timeout can mark phase = 'timeout'.
121
- while (true) {
122
- const elapsed = Date.now() - startedAt;
123
- if (elapsed > hardTimeout) {
124
- appendDomPickerLog('hard-timeout', { sessionId, elapsed });
125
- return {
126
- success: false,
127
- error: 'domPicker hard timeout',
128
- cancelled: false,
129
- timeout: true,
130
- };
131
- }
132
- const state = await page.evaluate(() => {
133
- const w = window;
134
- return w.__domPicker ? w.__domPicker.getLastState() : null;
135
- });
136
- if (!state) {
137
- appendDomPickerLog('state-missing', { sessionId });
138
- return {
139
- success: false,
140
- error: 'domPicker state unavailable',
141
- cancelled: false,
142
- timeout: false,
143
- };
144
- }
145
- const phase = state.phase;
146
- if (phase === 'selected' && state.selection) {
147
- const sel = state.selection;
148
- appendDomPickerLog('selected', { sessionId, selection: sel });
149
- this.broadcastEvent('dom.picker.result', sessionId, {
150
- success: true,
151
- dom_path: sel.path || '',
152
- selector: sel.selector || '',
153
- bounding_rect: sel.rect || { x: 0, y: 0, width: 0, height: 0 },
154
- tag: sel.tag || '',
155
- id: sel.id || null,
156
- classes: Array.isArray(sel.classes) ? sel.classes : [],
157
- text: sel.text || '',
158
- });
159
- return {
160
- success: true,
161
- dom_path: sel.path || '',
162
- selector: sel.selector || '',
163
- bounding_rect: sel.rect || { x: 0, y: 0, width: 0, height: 0 },
164
- tag: sel.tag || '',
165
- id: sel.id || null,
166
- classes: Array.isArray(sel.classes) ? sel.classes : [],
167
- text: sel.text || '',
168
- cancelled: false,
169
- timeout: false,
170
- };
171
- }
172
- if (phase === 'cancelled') {
173
- appendDomPickerLog('cancelled', { sessionId });
174
- return {
175
- success: false,
176
- error: state.error || 'cancelled',
177
- dom_path: null,
178
- selector: null,
179
- bounding_rect: null,
180
- tag: null,
181
- id: null,
182
- classes: [],
183
- text: '',
184
- cancelled: true,
185
- timeout: false,
186
- };
187
- }
188
- if (phase === 'timeout') {
189
- appendDomPickerLog('timeout', { sessionId });
190
- return {
191
- success: false,
192
- error: state.error || 'timeout',
193
- dom_path: null,
194
- selector: null,
195
- bounding_rect: null,
196
- tag: null,
197
- id: null,
198
- classes: [],
199
- text: '',
200
- cancelled: false,
201
- timeout: true,
202
- };
203
- }
204
- await new Promise((r) => setTimeout(r, 100));
205
- }
206
- }
207
- async stop() {
208
- if (!this.wss)
209
- return;
210
- await new Promise((resolve) => this.wss?.close(() => resolve()));
211
- this.wss = undefined;
212
- }
213
- async handleDomPickerLoopback(session, parameters) {
214
- const page = await session.ensurePage();
215
- const selector = parameters.selector || 'body';
216
- const timeoutMs = Math.min(Math.max(Number(parameters?.timeout) || 10000, 1000), 60000);
217
- const settleMs = Math.min(Math.max(Number(parameters?.settle_ms) || 32, 0), 2000);
218
- const sessionId = session?.id || 'unknown';
219
- appendDomPickerLog('loopback_start', { sessionId, selector, timeoutMs, settleMs });
220
- await ensurePageRuntime(page);
221
- // Real loopback: compute element center, move the real browser mouse, then read picker state.
222
- const prep = await page.evaluate((sel) => {
223
- const runtime = window.__camoRuntime;
224
- const picker = window.__domPicker;
225
- if (!runtime || !runtime.ready) {
226
- return { ok: false, error: '__camoRuntime not ready' };
227
- }
228
- if (!picker || typeof picker.startSession !== 'function' || typeof picker.getLastState !== 'function') {
229
- return { ok: false, error: '__domPicker unavailable' };
230
- }
231
- const info = picker.findElementCenter ? picker.findElementCenter(sel) : null;
232
- const el = typeof sel === 'string' ? document.querySelector(sel) : null;
233
- if (!info || !info.found || !el) {
234
- return { ok: false, error: 'selector_not_found' };
235
- }
236
- const point = { x: Math.round(info.x), y: Math.round(info.y) };
237
- const rect = info.rect;
238
- const buildPath = runtime?.dom?.buildPathForElement;
239
- const targetPath = buildPath && el instanceof Element ? buildPath(el, null) : null;
240
- const fromPoint = document.elementFromPoint(point.x, point.y);
241
- const fromPointPath = buildPath && fromPoint instanceof Element ? buildPath(fromPoint, null) : null;
242
- const before = picker.getLastState();
243
- if (!before?.phase || before.phase === 'idle') {
244
- picker.startSession({ timeoutMs: 8000 });
245
- }
246
- return {
247
- ok: true,
248
- selector: sel,
249
- point,
250
- targetRect: rect,
251
- targetPath,
252
- fromPointPath,
253
- stateBefore: before,
254
- };
255
- }, selector);
256
- if (!prep?.ok) {
257
- appendDomPickerLog('loopback_runtime_missing', { sessionId, selector, error: prep?.error });
258
- return { success: false, error: prep?.error || 'loopback_prep_failed' };
259
- }
260
- // mouse:move is globally disabled for runtime stability.
261
- if (settleMs > 0) {
262
- await new Promise((r) => setTimeout(r, settleMs));
263
- }
264
- const after = await page.evaluate(() => {
265
- const picker = window.__domPicker;
266
- return picker?.getLastState?.() || null;
267
- });
268
- await page.evaluate((sel) => {
269
- const runtime = window.__camoRuntime;
270
- runtime?.highlight?.highlightSelector?.(sel, { persistent: true, channel: 'dom-picker-loopback' });
271
- }, prep.selector);
272
- const result = {
273
- selector: prep.selector,
274
- point: prep.point,
275
- targetRect: prep.targetRect,
276
- hoveredPath: after?.selection?.path || after?.hovered?.path || after?.selected?.path || after?.path || null,
277
- targetPath: prep.targetPath,
278
- fromPointPath: prep.fromPointPath,
279
- overlayRect: after?.selection?.rect || after?.hovered?.rect || after?.selected?.rect || after?.rect || null,
280
- stateBefore: prep.stateBefore,
281
- stateAfter: after,
282
- matches: Boolean(prep.targetPath) &&
283
- (after?.selection?.path || after?.hovered?.path || after?.selected?.path || after?.path) === prep.targetPath &&
284
- Boolean(after?.selection?.rect || after?.hovered?.rect || after?.selected?.rect || after?.rect),
285
- };
286
- appendDomPickerLog('loopback_result', { sessionId, selector, result });
287
- return {
288
- success: true,
289
- data: result,
290
- };
291
- }
292
- async handleMessage(socket, raw) {
293
- let payload;
294
- try {
295
- payload = JSON.parse(this.rawToString(raw));
296
- }
297
- catch (err) {
298
- this.send(socket, {
299
- type: 'error',
300
- message: `Invalid JSON payload: ${err.message}`,
301
- });
302
- return;
303
- }
304
- logDebug('browser-service', 'wsMessage', { payload });
305
- if (payload?.type === 'subscribe') {
306
- await this.handleSubscribe(socket, payload);
307
- return;
308
- }
309
- if (payload?.type === 'unsubscribe') {
310
- await this.handleUnsubscribe(socket, payload);
311
- return;
312
- }
313
- if (payload?.type !== 'command') {
314
- this.send(socket, {
315
- type: 'error',
316
- message: 'Unsupported message type',
317
- });
318
- return;
319
- }
320
- const sessionId = String(payload.session_id || '');
321
- const requestId = String(payload.request_id || '');
322
- const command = payload.data || {};
323
- logDebug('browser-service', 'ws-command', { type: 'command', request_id: requestId, session_id: sessionId, data: command });
324
- try {
325
- const data = await this.dispatchCommand(sessionId, command);
326
- const response = {
327
- type: 'response',
328
- request_id: requestId,
329
- session_id: sessionId,
330
- data,
331
- };
332
- logDebug('browser-service', 'wsResponse', { response });
333
- this.send(socket, response);
334
- }
335
- catch (err) {
336
- const errorResponse = {
337
- type: 'response',
338
- request_id: requestId,
339
- session_id: sessionId,
340
- data: {
341
- success: false,
342
- error: err.message,
343
- },
344
- };
345
- logDebug('browser-service', 'wsError', { errorResponse });
346
- this.send(socket, errorResponse);
347
- }
348
- }
349
- async dispatchCommand(sessionId, command) {
350
- const type = command.command_type;
351
- switch (type) {
352
- case 'browser_state':
353
- return this.handleSessionControl(sessionId, {
354
- ...command,
355
- action: command.action || 'list',
356
- });
357
- case 'page_control':
358
- return this.handlePageControl(sessionId, command);
359
- case 'dom_operation':
360
- return this.handleDomOperation(sessionId, command);
361
- case 'user_action':
362
- return this.handleUserAction(sessionId, command);
363
- case 'highlight':
364
- return this.handleHighlight(sessionId, command);
365
- case 'session_control':
366
- return this.handleSessionControl(sessionId, command);
367
- case 'mode_switch':
368
- return this.handleModeSwitch(sessionId, command);
369
- case 'container_operation':
370
- return this.handleContainerOperation(sessionId, command);
371
- case 'node_execute':
372
- return this.handleNodeExecute(sessionId, command);
373
- case 'dev_control':
374
- return this.handleDevControl(sessionId, command);
375
- case 'dev_command':
376
- return this.handleDevCommand(sessionId, command);
377
- default:
378
- throw new Error(`Unknown command_type: ${type}`);
379
- }
380
- }
381
- async handleSubscribe(socket, payload) {
382
- const requestId = String(payload.request_id || '');
383
- const sessionId = String(payload.session_id || '');
384
- const topics = Array.isArray(payload.data?.topics) ? payload.data.topics : [];
385
- if (!sessionId) {
386
- this.send(socket, {
387
- type: 'error',
388
- request_id: requestId,
389
- message: 'session_id required for subscribe',
390
- });
391
- return;
392
- }
393
- const clientTopics = this.subscriptions.get(socket) || new Set();
394
- const sessionClients = this.sessionSubscribers.get(sessionId) || new Set();
395
- const requiresRuntimeBridge = topics.some((topic) => topic === 'browser.runtime.event' || topic.startsWith('browser.runtime.event.'));
396
- topics.forEach((topic) => {
397
- clientTopics.add(topic);
398
- sessionClients.add(socket);
399
- });
400
- this.subscriptions.set(socket, clientTopics);
401
- this.sessionSubscribers.set(sessionId, sessionClients);
402
- if (requiresRuntimeBridge) {
403
- this.ensureRuntimeEventBridge(sessionId);
404
- }
405
- this.send(socket, {
406
- type: 'response',
407
- request_id: requestId,
408
- session_id: sessionId,
409
- data: { success: true, subscribed: topics },
410
- });
411
- }
412
- async handleUnsubscribe(socket, payload) {
413
- const requestId = String(payload.request_id || '');
414
- const sessionId = String(payload.session_id || '');
415
- const topics = Array.isArray(payload.data?.topics) ? payload.data.topics : [];
416
- const clientTopics = this.subscriptions.get(socket);
417
- if (!clientTopics) {
418
- this.send(socket, {
419
- type: 'response',
420
- request_id: requestId,
421
- session_id: sessionId,
422
- data: { success: true, unsubscribed: [] },
423
- });
424
- return;
425
- }
426
- topics.forEach((topic) => clientTopics.delete(topic));
427
- if (clientTopics.size === 0) {
428
- this.subscriptions.delete(socket);
429
- }
430
- this.send(socket, {
431
- type: 'response',
432
- request_id: requestId,
433
- session_id: sessionId,
434
- data: { success: true, unsubscribed: topics },
435
- });
436
- }
437
- handleSocketClose(socket) {
438
- this.subscriptions.delete(socket);
439
- for (const [sessionId, clients] of this.sessionSubscribers.entries()) {
440
- clients.delete(socket);
441
- if (clients.size === 0) {
442
- this.sessionSubscribers.delete(sessionId);
443
- }
444
- }
445
- }
446
- broadcastEvent(topic, sessionId, data) {
447
- const clients = this.sessionSubscribers.get(sessionId);
448
- if (!clients)
449
- return;
450
- const payload = {
451
- type: 'event',
452
- topic,
453
- session_id: sessionId,
454
- data,
455
- };
456
- logDebug('browser-service', 'runtimeEvent:broadcast', { topic, sessionId, listeners: clients.size });
457
- clients.forEach((socket) => {
458
- const clientTopics = this.subscriptions.get(socket);
459
- if (clientTopics?.has(topic)) {
460
- try {
461
- socket.send(JSON.stringify(payload));
462
- }
463
- catch (err) {
464
- console.warn('[browser-ws] failed to broadcast event:', err);
465
- }
466
- }
467
- });
468
- }
469
- ensureRuntimeEventBridge(sessionId) {
470
- if (this.runtimeBridgeUnsub.has(sessionId))
471
- return;
472
- const session = this.options.sessionManager.getSession(sessionId);
473
- if (!session)
474
- return;
475
- const unsub = session.addRuntimeEventObserver((event) => {
476
- const data = { event, received_at: Date.now() };
477
- this.broadcastEvent('browser.runtime.event', sessionId, data);
478
- if (event?.type) {
479
- this.broadcastEvent(this.formatRuntimeTopic(event.type), sessionId, data);
480
- }
481
- });
482
- this.runtimeBridgeUnsub.set(sessionId, unsub);
483
- }
484
- teardownRuntimeEventBridge(sessionId) {
485
- const unsub = this.runtimeBridgeUnsub.get(sessionId);
486
- if (unsub) {
487
- try {
488
- unsub();
489
- }
490
- catch { }
491
- this.runtimeBridgeUnsub.delete(sessionId);
492
- }
493
- }
494
- formatRuntimeTopic(type) {
495
- const safe = (type || 'unknown').replace(/[^a-zA-Z0-9_.-]/g, '_').toLowerCase();
496
- return `browser.runtime.event.${safe}`;
497
- }
498
- async handlePageControl(sessionId, command) {
499
- const action = command.action;
500
- const parameters = command.parameters || {};
501
- if (!sessionId)
502
- throw new Error('session_id required');
503
- const session = this.options.sessionManager.getSession(sessionId);
504
- if (!session) {
505
- return { success: false, error: `Session ${sessionId} not found` };
506
- }
507
- if (action === 'navigate') {
508
- const url = parameters.url;
509
- if (!url)
510
- throw new Error('navigate requires url');
511
- await session.goto(url);
512
- return { success: true, data: { action: 'navigated', url } };
513
- }
514
- if (action === 'screenshot') {
515
- const filename = parameters.filename || `screenshot_${Date.now()}.png`;
516
- const fullPage = parameters.full_page !== false;
517
- const dir = path.resolve(process.cwd(), 'screenshots');
518
- await fs.promises.mkdir(dir, { recursive: true });
519
- const target = path.join(dir, filename);
520
- const buffer = await session.screenshot(fullPage);
521
- await fs.promises.writeFile(target, buffer);
522
- return { success: true, data: { action: 'screenshot', screenshot_path: target, full_page: fullPage } };
523
- }
524
- throw new Error(`Unknown page_control action: ${action}`);
525
- }
526
- async handleDomOperation(sessionId, command) {
527
- const action = command.action;
528
- const parameters = command.parameters || {};
529
- if (action === 'pick_dom') {
530
- const session = this.options.sessionManager.getSession(sessionId);
531
- if (!session) {
532
- return { success: false, error: `Session ${sessionId} not found` };
533
- }
534
- return this.handleDomPick(session, parameters);
535
- }
536
- if (action === 'query') {
537
- return this.handleNodeExecute(sessionId, { command_type: 'node_execute', node_type: 'query', parameters });
538
- }
539
- if (action === 'dom_full') {
540
- return this.handleDomFull(sessionId, parameters);
541
- }
542
- if (action === 'dom_branch') {
543
- return this.handleDomBranch(sessionId, parameters);
544
- }
545
- throw new Error(`Unknown dom_operation action: ${action}`);
546
- }
547
- async handleDomFull(sessionId, parameters) {
548
- const session = this.options.sessionManager.getSession(sessionId);
549
- if (!session) {
550
- return { success: false, error: 'Session ' + sessionId + ' not found' };
551
- }
552
- const page = await session.ensurePage();
553
- const rootSelector = parameters.root_selector || parameters.rootSelector || null;
554
- const maxDepth = Number(parameters.max_depth || parameters.maxDepth || 8);
555
- await ensurePageRuntime(page);
556
- const domTree = await page.evaluate((config) => {
557
- const runtime = window.__camoRuntime;
558
- if (!runtime?.getDomBranch) {
559
- throw new Error('runtime.getDomBranch unavailable');
560
- }
561
- return runtime.getDomBranch('root', {
562
- rootSelector: config.rootSelector,
563
- maxDepth: config.maxDepth,
564
- maxChildren: 100,
565
- });
566
- }, { rootSelector, maxDepth });
567
- const node = domTree.node || {};
568
- const nodeCount = node.childCount || 0;
569
- this.broadcastEvent('dom.updated', sessionId, {
570
- root_path: domTree.path || 'root',
571
- node_count: nodeCount,
572
- });
573
- return {
574
- success: true,
575
- data: {
576
- root_path: domTree.path || 'root',
577
- node_count: nodeCount,
578
- snapshot: node,
579
- },
580
- };
581
- }
582
- async handleDomBranch(sessionId, parameters) {
583
- const session = this.options.sessionManager.getSession(sessionId);
584
- if (!session) {
585
- return { success: false, error: 'Session ' + sessionId + ' not found' };
586
- }
587
- const page = await session.ensurePage();
588
- const domPath = String(parameters.dom_path || parameters.domPath || '');
589
- const depth = Number(parameters.depth || 3);
590
- const rootSelector = parameters.root_selector || parameters.rootSelector || null;
591
- await ensurePageRuntime(page);
592
- const snapshot = await page.evaluate((config) => {
593
- const runtime = window.__camoRuntime;
594
- if (!runtime?.getDomBranch) {
595
- throw new Error('runtime.getDomBranch unavailable');
596
- }
597
- return runtime.getDomBranch(config.domPath, {
598
- rootSelector: config.rootSelector,
599
- maxDepth: config.depth,
600
- maxChildren: 100,
601
- });
602
- }, { domPath, depth, rootSelector });
603
- const node = snapshot.node || {};
604
- return {
605
- success: true,
606
- data: {
607
- path: snapshot.path || domPath,
608
- node_count: node.childCount || 0,
609
- children: node.children || [],
610
- },
611
- };
612
- }
613
- async handleUserAction(sessionId, command) {
614
- const action = command.action;
615
- const parameters = command.parameters || {};
616
- if (action !== 'operation') {
617
- throw new Error(`Unknown user_action action: ${action}`);
618
- }
619
- const op = parameters.operation_type;
620
- if (!op)
621
- throw new Error('operation_type required');
622
- if (op === 'click') {
623
- return legacySelectorActionDisabled('user_action.operation', 'click');
624
- }
625
- if (op === 'type') {
626
- return legacySelectorActionDisabled('user_action.operation', 'type');
627
- }
628
- if (op === 'scroll') {
629
- const session = this.options.sessionManager.getSession(sessionId);
630
- if (!session) {
631
- return { success: false, error: `Session ${sessionId} not found` };
632
- }
633
- const page = await session.ensurePage();
634
- const target = parameters.target || {};
635
- const coordinates = target.coordinates || null;
636
- const deltaY = Number(parameters.deltaY ?? target.deltaY ?? parameters.delta_y ?? target.delta_y ?? 0);
637
- await page.mouse.wheel(0, deltaY);
638
- this.broadcastEvent('user_action.completed', sessionId, {
639
- action: 'scroll',
640
- target: coordinates
641
- ? `coordinates(${coordinates.x}, ${coordinates.y})`
642
- : '',
643
- duration_ms: 0,
644
- deltaY,
645
- });
646
- return {
647
- success: true,
648
- data: {
649
- action: 'scroll',
650
- deltaY,
651
- ...(coordinates ? { x: coordinates.x, y: coordinates.y } : {}),
652
- },
653
- };
654
- }
655
- if (op === 'move' || op === 'down' || op === 'up' || op === 'key') {
656
- return this.handleExtendedUserAction(sessionId, op, parameters.target || {}, parameters);
657
- }
658
- throw new Error(`Unsupported operation_type: ${op}`);
659
- }
660
- async handleExtendedUserAction(sessionId, opType, target, params) {
661
- const session = this.options.sessionManager.getSession(sessionId);
662
- if (!session) {
663
- return { success: false, error: 'Session ' + sessionId + ' not found' };
664
- }
665
- const page = await session.ensurePage();
666
- const startedAt = Date.now();
667
- const offset = target.offset || { x: 0, y: 0 };
668
- const coordinates = target.coordinates || null;
669
- const domPath = target.dom_path || null;
670
- const selector = target.selector || null;
671
- let coords = null;
672
- // Support direct coordinates
673
- if (coordinates && typeof coordinates.x === 'number' && typeof coordinates.y === 'number') {
674
- coords = { x: coordinates.x + offset.x, y: coordinates.y + offset.y };
675
- }
676
- else if (domPath) {
677
- await ensurePageRuntime(page);
678
- const result = await page.evaluate((config) => {
679
- const runtime = window.__camoRuntime;
680
- if (!runtime?.dom?.resolveByPath)
681
- return null;
682
- const el = runtime.dom.resolveByPath(config.path, config.rootSelector);
683
- if (!el)
684
- return null;
685
- const rect = el.getBoundingClientRect();
686
- return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
687
- }, { path: domPath, rootSelector: null });
688
- if (result) {
689
- coords = { x: result.x + offset.x, y: result.y + offset.y };
690
- }
691
- }
692
- else if (selector) {
693
- const element = await page.$(selector);
694
- if (element) {
695
- const box = await element.boundingBox();
696
- if (box) {
697
- coords = { x: box.x + box.width / 2 + offset.x, y: box.y + box.height / 2 + offset.y };
698
- }
699
- }
700
- }
701
- if (opType === 'move') {
702
- throw new Error('mouse:move disabled');
703
- }
704
- else if (opType === 'down') {
705
- if (coords) {
706
- await page.mouse.down();
707
- }
708
- }
709
- else if (opType === 'up') {
710
- await page.mouse.up();
711
- }
712
- else if (opType === 'key') {
713
- const key = params.key || '';
714
- if (key) {
715
- await page.keyboard.press(key);
716
- }
717
- }
718
- const duration = Date.now() - startedAt;
719
- this.broadcastEvent('user_action.completed', sessionId, {
720
- action: opType,
721
- target: domPath || selector || (coordinates ? `coordinates(${coordinates.x}, ${coordinates.y})` : ''),
722
- duration_ms: duration,
723
- ...(coords ? { x: coords.x, y: coords.y } : {}),
724
- });
725
- return {
726
- success: true,
727
- data: {
728
- action: opType,
729
- target: domPath || selector || (coordinates ? `coordinates(${coordinates.x}, ${coordinates.y})` : ''),
730
- duration_ms: duration,
731
- },
732
- };
733
- }
734
- async handleHighlight(sessionId, command) {
735
- const action = command.action;
736
- const parameters = command.parameters || {};
737
- if (action === 'element') {
738
- return this.handleDevCommand(sessionId, { command_type: 'dev_command', action: 'highlight_element', parameters });
739
- }
740
- if (action === 'dom_path') {
741
- return this.handleDevCommand(sessionId, { command_type: 'dev_command', action: 'highlight_dom_path', parameters });
742
- }
743
- throw new Error(`Unknown highlight action: ${action}`);
744
- }
745
- async handleSessionControl(sessionId, command) {
746
- const action = command.action;
747
- if (action === 'create') {
748
- const capabilities = command.capabilities || ['dom'];
749
- const browserConfig = command.browser_config || {};
750
- const profileId = browserConfig.profile_id || browserConfig.session_name || `session_${Date.now().toString(36)}`;
751
- const headless = browserConfig.headless ?? false;
752
- const viewport = browserConfig.viewport;
753
- const userAgent = browserConfig.user_agent;
754
- const initialUrl = browserConfig.initial_url || command.initial_url;
755
- const result = await this.options.sessionManager.createSession({
756
- profileId,
757
- sessionName: browserConfig.session_name || profileId,
758
- headless,
759
- viewport,
760
- userAgent,
761
- initialUrl,
762
- });
763
- this.capabilityMap.set(result.sessionId, capabilities);
764
- return {
765
- success: true,
766
- session_id: result.sessionId,
767
- status: 'ready',
768
- capabilities,
769
- };
770
- }
771
- if (action === 'list') {
772
- const sessions = this.options.sessionManager.listSessions().map((session) => ({
773
- session_id: session.session_id || session.profileId,
774
- profileId: session.profileId,
775
- current_url: session.current_url,
776
- mode: session.mode,
777
- status: 'ready',
778
- capabilities: this.capabilityMap.get(session.profileId) || [],
779
- }));
780
- return {
781
- success: true,
782
- sessions,
783
- };
784
- }
785
- if (action === 'info') {
786
- if (!sessionId) {
787
- throw new Error('session_id required for info action');
788
- }
789
- const info = await this.options.sessionManager.getSessionInfo(sessionId);
790
- if (!info) {
791
- return {
792
- success: false,
793
- error: `Session ${sessionId} not found`,
794
- };
795
- }
796
- return {
797
- success: true,
798
- session_info: {
799
- ...info,
800
- capabilities: this.capabilityMap.get(sessionId) || [],
801
- },
802
- };
803
- }
804
- if (action === 'delete') {
805
- if (!sessionId) {
806
- throw new Error('session_id required for delete action');
807
- }
808
- const deleted = await this.options.sessionManager.deleteSession(sessionId);
809
- this.capabilityMap.delete(sessionId);
810
- return {
811
- success: deleted,
812
- session_id: sessionId,
813
- };
814
- }
815
- throw new Error(`Unknown session action: ${action}`);
816
- }
817
- async handleModeSwitch(sessionId, command) {
818
- if (!sessionId) {
819
- throw new Error('session_id required for mode switch');
820
- }
821
- const session = this.options.sessionManager.getSession(sessionId);
822
- if (!session) {
823
- return {
824
- success: false,
825
- error: `Session ${sessionId} not found`,
826
- };
827
- }
828
- const target = command.target_mode || 'dev';
829
- session.setMode(target);
830
- return {
831
- success: true,
832
- session_id: sessionId,
833
- new_mode: target,
834
- };
835
- }
836
- async handleContainerOperation(sessionId, command) {
837
- if (!sessionId) {
838
- throw new Error('session_id required for container operations');
839
- }
840
- const session = this.options.sessionManager.getSession(sessionId);
841
- if (!session) {
842
- return {
843
- success: false,
844
- error: `Session ${sessionId} not found`,
845
- };
846
- }
847
- logDebug('browser-service', 'containerOperation', { sessionId, command });
848
- const pageContext = command.page_context || {};
849
- if (command.action === 'match_root') {
850
- const match = await this.matcher.matchRoot(session, pageContext);
851
- if (!match) {
852
- return {
853
- success: false,
854
- error: 'No matching container found',
855
- };
856
- }
857
- return {
858
- success: true,
859
- data: {
860
- container: match.container,
861
- selector: match.container?.matched_selector || match.match_details?.selector,
862
- domPath: match.match_details?.dom_path || null,
863
- match_details: match.match_details,
864
- },
865
- };
866
- }
867
- if (command.action === 'inspect_tree') {
868
- const snapshot = await this.matcher.inspectTree(session, pageContext, command.parameters || {});
869
- return {
870
- success: true,
871
- data: snapshot,
872
- };
873
- }
874
- if (command.action === 'inspect_dom_branch') {
875
- const branch = await this.matcher.inspectDomBranch(session, pageContext, command.parameters || {});
876
- return {
877
- success: true,
878
- data: branch,
879
- };
880
- }
881
- throw new Error(`Unsupported container action: ${command.action}`);
882
- }
883
- async handleNodeExecute(sessionId, command) {
884
- if (!sessionId) {
885
- throw new Error('session_id required for node_execute');
886
- }
887
- const session = this.options.sessionManager.getSession(sessionId);
888
- if (!session) {
889
- return {
890
- success: false,
891
- error: `Session ${sessionId} not found`,
892
- };
893
- }
894
- const nodeType = command.node_type;
895
- const parameters = command.parameters || {};
896
- switch (nodeType) {
897
- case 'navigate': {
898
- const url = parameters.url;
899
- if (!url) {
900
- throw new Error('Navigate node requires url');
901
- }
902
- await session.goto(url);
903
- return {
904
- success: true,
905
- data: {
906
- action: 'navigated',
907
- url,
908
- },
909
- };
910
- }
911
- case 'click': {
912
- return legacySelectorActionDisabled('node_execute', 'click');
913
- }
914
- case 'type': {
915
- return legacySelectorActionDisabled('node_execute', 'type');
916
- }
917
- case 'screenshot': {
918
- const filename = parameters.filename || `screenshot_${Date.now()}.png`;
919
- const fullPage = parameters.full_page !== false;
920
- const dir = path.resolve(process.cwd(), 'screenshots');
921
- await fs.promises.mkdir(dir, { recursive: true });
922
- const target = path.join(dir, filename);
923
- const buffer = await session.screenshot(fullPage);
924
- await fs.promises.writeFile(target, buffer);
925
- return {
926
- success: true,
927
- data: {
928
- action: 'screenshot',
929
- screenshot_path: target,
930
- full_page: fullPage,
931
- },
932
- };
933
- }
934
- case 'query': {
935
- const selector = parameters.selector;
936
- if (!selector)
937
- throw new Error('Query node requires selector');
938
- const limit = Number(parameters.max_items || parameters.maxItems || 5);
939
- const page = await session.ensurePage();
940
- const result = await page.$$eval(selector, (els, lim) => {
941
- const sample = [];
942
- const max = Math.max(0, Number(lim) || 0);
943
- for (let i = 0; i < Math.min(max, els.length); i++) {
944
- const el = els[i];
945
- sample.push({
946
- tag: el.tagName,
947
- id: el.id || null,
948
- classes: Array.from(el.classList || []),
949
- text: (el.textContent || '').trim().slice(0, 120),
950
- });
951
- }
952
- return {
953
- count: els.length,
954
- sample,
955
- };
956
- }, limit);
957
- return {
958
- success: true,
959
- data: {
960
- selector,
961
- count: result.count,
962
- sample: result.sample,
963
- },
964
- };
965
- }
966
- case 'dom_info': {
967
- const page = await session.ensurePage();
968
- const info = await page.evaluate(() => {
969
- const doc = document;
970
- const html = doc.documentElement;
971
- const body = doc.body;
972
- const serialize = (el) => {
973
- if (!el)
974
- return null;
975
- return {
976
- tag: el.tagName,
977
- id: el.id || null,
978
- classes: Array.from(el.classList || []),
979
- };
980
- };
981
- const firstChildren = (el, limit = 8) => {
982
- if (!el || !el.children)
983
- return [];
984
- return Array.from(el.children)
985
- .slice(0, limit)
986
- .map((child) => serialize(child));
987
- };
988
- return {
989
- html: serialize(html),
990
- body: serialize(body),
991
- app: serialize(doc.getElementById('app')),
992
- appChildren: firstChildren(doc.getElementById('app')),
993
- bodyChildren: firstChildren(body),
994
- };
995
- });
996
- return {
997
- success: true,
998
- data: info,
999
- };
1000
- }
1001
- case 'eval':
1002
- case 'evaluate':
1003
- case 'evaluate_js': {
1004
- const expression = parameters.expression || parameters.script;
1005
- if (!expression) {
1006
- throw new Error('Eval node requires expression');
1007
- }
1008
- const arg = parameters.arg;
1009
- const result = await session.evaluate(expression, arg);
1010
- return {
1011
- success: true,
1012
- data: { result },
1013
- };
1014
- }
1015
- case 'pick_dom': {
1016
- const result = await this.handleDomPick(session, parameters);
1017
- return {
1018
- success: true,
1019
- data: result,
1020
- };
1021
- }
1022
- case 'dom_pick_loopback': {
1023
- const result = await this.handleDomPickerLoopback(session, parameters);
1024
- return {
1025
- success: true,
1026
- data: result,
1027
- };
1028
- }
1029
- default:
1030
- throw new Error(`Unsupported node type: ${nodeType}`);
1031
- }
1032
- }
1033
- async handleDevControl(sessionId, command) {
1034
- if (command.action === 'enable_overlay') {
1035
- return {
1036
- success: true,
1037
- data: {
1038
- enabled: true,
1039
- message: 'Overlay not implemented in TS service yet',
1040
- },
1041
- };
1042
- }
1043
- return {
1044
- success: false,
1045
- error: `Unsupported dev control action: ${command.action}`,
1046
- };
1047
- }
1048
- async handleDevCommand(sessionId, command) {
1049
- if (!sessionId) {
1050
- throw new Error('session_id required for dev_command');
1051
- }
1052
- const session = this.options.sessionManager.getSession(sessionId);
1053
- if (!session) {
1054
- return {
1055
- success: false,
1056
- error: `Session ${sessionId} not found`,
1057
- };
1058
- }
1059
- const action = command.action;
1060
- const parameters = command.parameters || {};
1061
- switch (action) {
1062
- case 'highlight_element': {
1063
- const selector = (parameters.selector || '').trim();
1064
- if (!selector) {
1065
- return { success: false, error: 'selector required' };
1066
- }
1067
- const channel = (parameters.channel || 'ui-action').trim() || 'ui-action';
1068
- const style = typeof parameters.style === 'string' ? parameters.style : undefined;
1069
- const duration = typeof parameters.duration === 'number' ? parameters.duration : Number(parameters.duration || 0);
1070
- const sticky = typeof parameters.sticky === 'boolean' ? parameters.sticky : Boolean(parameters.hold || false);
1071
- const rootSelector = parameters.root_selector || parameters.rootSelector || null;
1072
- appendHighlightLog('request', { sessionId, channel, selector, style, duration, sticky, rootSelector });
1073
- const page = await session.ensurePage();
1074
- const result = await page.evaluate((config) => {
1075
- if (!window.__camoRuntime?.highlight?.highlightSelector) {
1076
- throw new Error('highlight runtime unavailable');
1077
- }
1078
- const res = window.__camoRuntime.highlight.highlightSelector(config.selector, {
1079
- channel: config.channel,
1080
- ...(config.style ? { style: config.style } : {}),
1081
- ...(Number.isFinite(config.duration) && config.duration > 0 ? { duration: config.duration } : {}),
1082
- ...(typeof config.sticky === 'boolean' ? { sticky: config.sticky } : {}),
1083
- ...(config.rootSelector ? { rootSelector: config.rootSelector } : {}),
1084
- });
1085
- const count = typeof res === 'number' ? res : Number(res?.count || res?.matched || 0);
1086
- return { count: Number.isFinite(count) ? count : 0, channel: config.channel };
1087
- }, { selector, channel, style, duration: Number.isFinite(duration) ? duration : 0, sticky, rootSelector });
1088
- appendHighlightLog('result', { sessionId, channel, selector, count: result?.count || 0 });
1089
- return { success: true, data: result };
1090
- }
1091
- case 'clear_highlight': {
1092
- const channel = (parameters.channel || 'ui-action').trim() || 'ui-action';
1093
- appendHighlightLog('clear', { sessionId, channel });
1094
- const page = await session.ensurePage();
1095
- await page.evaluate((ch) => {
1096
- window.__camoRuntime?.highlight?.clear?.(ch);
1097
- }, channel);
1098
- return {
1099
- success: true,
1100
- data: { cleared: true },
1101
- };
1102
- }
1103
- case 'highlight_dom_path': {
1104
- const path = (parameters.path || parameters.dom_path || '').trim();
1105
- if (!path) {
1106
- return { success: false, error: 'path required' };
1107
- }
1108
- const channel = (parameters.channel || 'ui-action').trim() || 'ui-action';
1109
- const style = typeof parameters.style === 'string' ? parameters.style : undefined;
1110
- const duration = typeof parameters.duration === 'number' ? parameters.duration : Number(parameters.duration || 0);
1111
- const sticky = typeof parameters.sticky === 'boolean' ? parameters.sticky : Boolean(parameters.hold || false);
1112
- const rootSelector = parameters.root_selector || parameters.rootSelector || null;
1113
- appendHighlightLog('request', { sessionId, channel, path, style, duration, sticky, rootSelector });
1114
- const page = await session.ensurePage();
1115
- const result = await page.evaluate((config) => {
1116
- const runtime = window.__camoRuntime;
1117
- if (!runtime?.highlight?.highlightElements) {
1118
- throw new Error('highlight runtime unavailable');
1119
- }
1120
- if (!runtime?.dom?.resolveByPath) {
1121
- throw new Error('dom resolveByPath unavailable');
1122
- }
1123
- const node = runtime.dom.resolveByPath(config.path, config.rootSelector || null);
1124
- if (!node)
1125
- return { count: 0, channel: config.channel };
1126
- runtime.highlight.highlightElements([node], {
1127
- channel: config.channel,
1128
- ...(config.style ? { style: config.style } : {}),
1129
- ...(Number.isFinite(config.duration) && config.duration > 0 ? { duration: config.duration } : {}),
1130
- ...(typeof config.sticky === 'boolean' ? { sticky: config.sticky } : {}),
1131
- ...(config.rootSelector ? { rootSelector: config.rootSelector } : {}),
1132
- });
1133
- return { count: 1, channel: config.channel };
1134
- }, { path, channel, style, duration: Number.isFinite(duration) ? duration : 0, sticky, rootSelector });
1135
- appendHighlightLog('result', { sessionId, channel, path, count: result?.count || 0 });
1136
- return { success: true, data: result };
1137
- }
1138
- case 'cancel_dom_pick': {
1139
- const result = await this.cancelDomPicker(session);
1140
- return {
1141
- success: true,
1142
- data: result,
1143
- };
1144
- }
1145
- default:
1146
- return {
1147
- success: false,
1148
- error: `Unsupported dev command: ${action}`,
1149
- };
1150
- }
1151
- }
1152
- async highlightViaRuntime() {
1153
- return { count: 0 };
1154
- }
1155
- async clearHighlightOverlays() {
1156
- return { cleared: 0 };
1157
- }
1158
- async cancelDomPicker(session) {
1159
- const page = await session.ensurePage();
1160
- return page.evaluate(() => {
1161
- const cancel = window.__camoDomPickerCancel;
1162
- if (typeof cancel === 'function') {
1163
- try {
1164
- cancel();
1165
- window.__camoDomPickerCancel = null;
1166
- return { cancelled: true };
1167
- }
1168
- catch (err) {
1169
- return { cancelled: false, error: err.message };
1170
- }
1171
- }
1172
- return { cancelled: false };
1173
- });
1174
- }
1175
- send(socket, payload) {
1176
- try {
1177
- socket.send(JSON.stringify(payload));
1178
- }
1179
- catch (err) {
1180
- console.error('[browser-ws] failed to send message:', err);
1181
- }
1182
- }
1183
- rawToString(data) {
1184
- if (typeof data === 'string')
1185
- return data;
1186
- if (Buffer.isBuffer(data))
1187
- return data.toString('utf-8');
1188
- if (Array.isArray(data)) {
1189
- return Buffer.concat(data).toString('utf-8');
1190
- }
1191
- return Buffer.from(data).toString('utf-8');
1192
- }
1193
- }
1194
- //# sourceMappingURL=ws-server.js.map
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { WebSocketServer } from 'ws';
5
+ import { SESSION_CLOSED_EVENT } from './SessionManager.js';
6
+ import { ContainerMatcher } from './container-matcher.js';
7
+ import { ensurePageRuntime } from './pageRuntime.js';
8
+ import { logDebug } from './logging.js';
9
+ import { CONFIG_DIR } from '../../../utils/config.mjs';
10
+ const logsDir = path.join(CONFIG_DIR || path.join(os.homedir(), '.camo'), 'logs');
11
+ const domPickerLogPath = path.join(logsDir, 'dom-picker-debug.log');
12
+ const highlightLogPath = path.join(logsDir, 'highlight-debug.log');
13
+ function appendLog(target, event, payload = {}) {
14
+ try {
15
+ fs.mkdirSync(path.dirname(target), { recursive: true });
16
+ const line = JSON.stringify({
17
+ ts: new Date().toISOString(),
18
+ event,
19
+ ...payload,
20
+ });
21
+ fs.appendFileSync(target, `${line}\n`, 'utf-8');
22
+ }
23
+ catch {
24
+ /* ignore log errors */
25
+ }
26
+ }
27
+ const appendDomPickerLog = (event, payload = {}) => appendLog(domPickerLogPath, event, payload);
28
+ const appendHighlightLog = (event, payload = {}) => appendLog(highlightLogPath, event, payload);
29
+ function legacySelectorActionDisabled(source, action) {
30
+ return {
31
+ success: false,
32
+ code: 'LEGACY_ACTION_DISABLED',
33
+ error: `[${source}] legacy selector action "${action}" is disabled; use mouse:* and keyboard:* protocol actions instead`,
34
+ data: {
35
+ source,
36
+ action,
37
+ replacements: ['mouse:move', 'mouse:click', 'mouse:wheel', 'keyboard:press', 'keyboard:type'],
38
+ },
39
+ };
40
+ }
41
+ export class BrowserWsServer {
42
+ options;
43
+ wss;
44
+ matcher = new ContainerMatcher();
45
+ capabilityMap = new Map();
46
+ subscriptions = new Map();
47
+ sessionSubscribers = new Map();
48
+ runtimeBridgeUnsub = new Map();
49
+ constructor(options) {
50
+ this.options = options;
51
+ process.on(SESSION_CLOSED_EVENT, (sessionId) => {
52
+ this.teardownRuntimeEventBridge(sessionId);
53
+ });
54
+ }
55
+ async start() {
56
+ if (this.wss)
57
+ return;
58
+ const host = this.options.host || '127.0.0.1';
59
+ const port = Number(this.options.port || 8765);
60
+ this.wss = new WebSocketServer({ host, port });
61
+ this.wss.on('connection', (socket) => {
62
+ this.subscriptions.set(socket, new Set());
63
+ socket.on('message', (data) => this.handleMessage(socket, data));
64
+ socket.on('close', () => {
65
+ this.handleSocketClose(socket);
66
+ });
67
+ });
68
+ this.wss.on('listening', () => {
69
+ console.log(`[browser-ws] listening on ws://${host}:${port}`);
70
+ });
71
+ this.wss.on('error', (err) => {
72
+ console.error('[browser-ws] server error:', err);
73
+ });
74
+ }
75
+ async handleDomPick(session, parameters) {
76
+ const timeoutMs = Math.min(Math.max(Number(parameters?.timeout) || 25000, 3000), 60000);
77
+ const page = await session.ensurePage();
78
+ const sessionId = session?.id || 'unknown';
79
+ appendDomPickerLog('start', { sessionId, timeoutMs });
80
+ await ensurePageRuntime(page);
81
+ try {
82
+ await page.bringToFront();
83
+ }
84
+ catch {
85
+ /* ignore */
86
+ }
87
+ const hasRuntime = await page.evaluate(() => {
88
+ const w = window;
89
+ return Boolean(w.__domPicker && typeof w.__domPicker.startSession === 'function' && typeof w.__domPicker.getLastState === 'function');
90
+ });
91
+ if (!hasRuntime) {
92
+ appendDomPickerLog('runtime-missing', { sessionId });
93
+ return {
94
+ success: false,
95
+ error: 'domPicker runtime unavailable',
96
+ cancelled: false,
97
+ timeout: false,
98
+ };
99
+ }
100
+ const rootSelector = parameters.root_selector || parameters.rootSelector || null;
101
+ await page.evaluate((opts) => {
102
+ const w = window;
103
+ try {
104
+ w.__domPicker.startSession({ mode: 'hover-select', timeoutMs: opts.timeoutMs, rootSelector: opts.rootSelector });
105
+ }
106
+ catch (err) {
107
+ // swallow, polling below will observe error/idle state
108
+ // eslint-disable-next-line no-console
109
+ console.warn('[dom-picker] startSession error', err);
110
+ }
111
+ }, { timeoutMs, rootSelector });
112
+ const startedState = await page.evaluate(() => {
113
+ const w = window;
114
+ return w.__domPicker ? w.__domPicker.getLastState() : null;
115
+ });
116
+ appendDomPickerLog('started', { sessionId, state: startedState });
117
+ const startedAt = Date.now();
118
+ const hardTimeout = timeoutMs + 2000;
119
+ // Poll state until selected / cancelled / timeout
120
+ // We keep polling even after logical timeoutMs so that page-side timeout can mark phase = 'timeout'.
121
+ while (true) {
122
+ const elapsed = Date.now() - startedAt;
123
+ if (elapsed > hardTimeout) {
124
+ appendDomPickerLog('hard-timeout', { sessionId, elapsed });
125
+ return {
126
+ success: false,
127
+ error: 'domPicker hard timeout',
128
+ cancelled: false,
129
+ timeout: true,
130
+ };
131
+ }
132
+ const state = await page.evaluate(() => {
133
+ const w = window;
134
+ return w.__domPicker ? w.__domPicker.getLastState() : null;
135
+ });
136
+ if (!state) {
137
+ appendDomPickerLog('state-missing', { sessionId });
138
+ return {
139
+ success: false,
140
+ error: 'domPicker state unavailable',
141
+ cancelled: false,
142
+ timeout: false,
143
+ };
144
+ }
145
+ const phase = state.phase;
146
+ if (phase === 'selected' && state.selection) {
147
+ const sel = state.selection;
148
+ appendDomPickerLog('selected', { sessionId, selection: sel });
149
+ this.broadcastEvent('dom.picker.result', sessionId, {
150
+ success: true,
151
+ dom_path: sel.path || '',
152
+ selector: sel.selector || '',
153
+ bounding_rect: sel.rect || { x: 0, y: 0, width: 0, height: 0 },
154
+ tag: sel.tag || '',
155
+ id: sel.id || null,
156
+ classes: Array.isArray(sel.classes) ? sel.classes : [],
157
+ text: sel.text || '',
158
+ });
159
+ return {
160
+ success: true,
161
+ dom_path: sel.path || '',
162
+ selector: sel.selector || '',
163
+ bounding_rect: sel.rect || { x: 0, y: 0, width: 0, height: 0 },
164
+ tag: sel.tag || '',
165
+ id: sel.id || null,
166
+ classes: Array.isArray(sel.classes) ? sel.classes : [],
167
+ text: sel.text || '',
168
+ cancelled: false,
169
+ timeout: false,
170
+ };
171
+ }
172
+ if (phase === 'cancelled') {
173
+ appendDomPickerLog('cancelled', { sessionId });
174
+ return {
175
+ success: false,
176
+ error: state.error || 'cancelled',
177
+ dom_path: null,
178
+ selector: null,
179
+ bounding_rect: null,
180
+ tag: null,
181
+ id: null,
182
+ classes: [],
183
+ text: '',
184
+ cancelled: true,
185
+ timeout: false,
186
+ };
187
+ }
188
+ if (phase === 'timeout') {
189
+ appendDomPickerLog('timeout', { sessionId });
190
+ return {
191
+ success: false,
192
+ error: state.error || 'timeout',
193
+ dom_path: null,
194
+ selector: null,
195
+ bounding_rect: null,
196
+ tag: null,
197
+ id: null,
198
+ classes: [],
199
+ text: '',
200
+ cancelled: false,
201
+ timeout: true,
202
+ };
203
+ }
204
+ await new Promise((r) => setTimeout(r, 100));
205
+ }
206
+ }
207
+ async stop() {
208
+ if (!this.wss)
209
+ return;
210
+ await new Promise((resolve) => this.wss?.close(() => resolve()));
211
+ this.wss = undefined;
212
+ }
213
+ async handleDomPickerLoopback(session, parameters) {
214
+ const page = await session.ensurePage();
215
+ const selector = parameters.selector || 'body';
216
+ const timeoutMs = Math.min(Math.max(Number(parameters?.timeout) || 10000, 1000), 60000);
217
+ const settleMs = Math.min(Math.max(Number(parameters?.settle_ms) || 32, 0), 2000);
218
+ const sessionId = session?.id || 'unknown';
219
+ appendDomPickerLog('loopback_start', { sessionId, selector, timeoutMs, settleMs });
220
+ await ensurePageRuntime(page);
221
+ // Real loopback: compute element center, move the real browser mouse, then read picker state.
222
+ const prep = await page.evaluate((sel) => {
223
+ const runtime = window.__camoRuntime;
224
+ const picker = window.__domPicker;
225
+ if (!runtime || !runtime.ready) {
226
+ return { ok: false, error: '__camoRuntime not ready' };
227
+ }
228
+ if (!picker || typeof picker.startSession !== 'function' || typeof picker.getLastState !== 'function') {
229
+ return { ok: false, error: '__domPicker unavailable' };
230
+ }
231
+ const info = picker.findElementCenter ? picker.findElementCenter(sel) : null;
232
+ const el = typeof sel === 'string' ? document.querySelector(sel) : null;
233
+ if (!info || !info.found || !el) {
234
+ return { ok: false, error: 'selector_not_found' };
235
+ }
236
+ const point = { x: Math.round(info.x), y: Math.round(info.y) };
237
+ const rect = info.rect;
238
+ const buildPath = runtime?.dom?.buildPathForElement;
239
+ const targetPath = buildPath && el instanceof Element ? buildPath(el, null) : null;
240
+ const fromPoint = document.elementFromPoint(point.x, point.y);
241
+ const fromPointPath = buildPath && fromPoint instanceof Element ? buildPath(fromPoint, null) : null;
242
+ const before = picker.getLastState();
243
+ if (!before?.phase || before.phase === 'idle') {
244
+ picker.startSession({ timeoutMs: 8000 });
245
+ }
246
+ return {
247
+ ok: true,
248
+ selector: sel,
249
+ point,
250
+ targetRect: rect,
251
+ targetPath,
252
+ fromPointPath,
253
+ stateBefore: before,
254
+ };
255
+ }, selector);
256
+ if (!prep?.ok) {
257
+ appendDomPickerLog('loopback_runtime_missing', { sessionId, selector, error: prep?.error });
258
+ return { success: false, error: prep?.error || 'loopback_prep_failed' };
259
+ }
260
+ // mouse:move is globally disabled for runtime stability.
261
+ if (settleMs > 0) {
262
+ await new Promise((r) => setTimeout(r, settleMs));
263
+ }
264
+ const after = await page.evaluate(() => {
265
+ const picker = window.__domPicker;
266
+ return picker?.getLastState?.() || null;
267
+ });
268
+ await page.evaluate((sel) => {
269
+ const runtime = window.__camoRuntime;
270
+ runtime?.highlight?.highlightSelector?.(sel, { persistent: true, channel: 'dom-picker-loopback' });
271
+ }, prep.selector);
272
+ const result = {
273
+ selector: prep.selector,
274
+ point: prep.point,
275
+ targetRect: prep.targetRect,
276
+ hoveredPath: after?.selection?.path || after?.hovered?.path || after?.selected?.path || after?.path || null,
277
+ targetPath: prep.targetPath,
278
+ fromPointPath: prep.fromPointPath,
279
+ overlayRect: after?.selection?.rect || after?.hovered?.rect || after?.selected?.rect || after?.rect || null,
280
+ stateBefore: prep.stateBefore,
281
+ stateAfter: after,
282
+ matches: Boolean(prep.targetPath) &&
283
+ (after?.selection?.path || after?.hovered?.path || after?.selected?.path || after?.path) === prep.targetPath &&
284
+ Boolean(after?.selection?.rect || after?.hovered?.rect || after?.selected?.rect || after?.rect),
285
+ };
286
+ appendDomPickerLog('loopback_result', { sessionId, selector, result });
287
+ return {
288
+ success: true,
289
+ data: result,
290
+ };
291
+ }
292
+ async handleMessage(socket, raw) {
293
+ let payload;
294
+ try {
295
+ payload = JSON.parse(this.rawToString(raw));
296
+ }
297
+ catch (err) {
298
+ this.send(socket, {
299
+ type: 'error',
300
+ message: `Invalid JSON payload: ${err.message}`,
301
+ });
302
+ return;
303
+ }
304
+ logDebug('browser-service', 'wsMessage', { payload });
305
+ if (payload?.type === 'subscribe') {
306
+ await this.handleSubscribe(socket, payload);
307
+ return;
308
+ }
309
+ if (payload?.type === 'unsubscribe') {
310
+ await this.handleUnsubscribe(socket, payload);
311
+ return;
312
+ }
313
+ if (payload?.type !== 'command') {
314
+ this.send(socket, {
315
+ type: 'error',
316
+ message: 'Unsupported message type',
317
+ });
318
+ return;
319
+ }
320
+ const sessionId = String(payload.session_id || '');
321
+ const requestId = String(payload.request_id || '');
322
+ const command = payload.data || {};
323
+ logDebug('browser-service', 'ws-command', { type: 'command', request_id: requestId, session_id: sessionId, data: command });
324
+ try {
325
+ const data = await this.dispatchCommand(sessionId, command);
326
+ const response = {
327
+ type: 'response',
328
+ request_id: requestId,
329
+ session_id: sessionId,
330
+ data,
331
+ };
332
+ logDebug('browser-service', 'wsResponse', { response });
333
+ this.send(socket, response);
334
+ }
335
+ catch (err) {
336
+ const errorResponse = {
337
+ type: 'response',
338
+ request_id: requestId,
339
+ session_id: sessionId,
340
+ data: {
341
+ success: false,
342
+ error: err.message,
343
+ },
344
+ };
345
+ logDebug('browser-service', 'wsError', { errorResponse });
346
+ this.send(socket, errorResponse);
347
+ }
348
+ }
349
+ async dispatchCommand(sessionId, command) {
350
+ const type = command.command_type;
351
+ switch (type) {
352
+ case 'browser_state':
353
+ return this.handleSessionControl(sessionId, {
354
+ ...command,
355
+ action: command.action || 'list',
356
+ });
357
+ case 'page_control':
358
+ return this.handlePageControl(sessionId, command);
359
+ case 'dom_operation':
360
+ return this.handleDomOperation(sessionId, command);
361
+ case 'user_action':
362
+ return this.handleUserAction(sessionId, command);
363
+ case 'highlight':
364
+ return this.handleHighlight(sessionId, command);
365
+ case 'session_control':
366
+ return this.handleSessionControl(sessionId, command);
367
+ case 'mode_switch':
368
+ return this.handleModeSwitch(sessionId, command);
369
+ case 'container_operation':
370
+ return this.handleContainerOperation(sessionId, command);
371
+ case 'node_execute':
372
+ return this.handleNodeExecute(sessionId, command);
373
+ case 'dev_control':
374
+ return this.handleDevControl(sessionId, command);
375
+ case 'dev_command':
376
+ return this.handleDevCommand(sessionId, command);
377
+ default:
378
+ throw new Error(`Unknown command_type: ${type}`);
379
+ }
380
+ }
381
+ async handleSubscribe(socket, payload) {
382
+ const requestId = String(payload.request_id || '');
383
+ const sessionId = String(payload.session_id || '');
384
+ const topics = Array.isArray(payload.data?.topics) ? payload.data.topics : [];
385
+ if (!sessionId) {
386
+ this.send(socket, {
387
+ type: 'error',
388
+ request_id: requestId,
389
+ message: 'session_id required for subscribe',
390
+ });
391
+ return;
392
+ }
393
+ const clientTopics = this.subscriptions.get(socket) || new Set();
394
+ const sessionClients = this.sessionSubscribers.get(sessionId) || new Set();
395
+ const requiresRuntimeBridge = topics.some((topic) => topic === 'browser.runtime.event' || topic.startsWith('browser.runtime.event.'));
396
+ topics.forEach((topic) => {
397
+ clientTopics.add(topic);
398
+ sessionClients.add(socket);
399
+ });
400
+ this.subscriptions.set(socket, clientTopics);
401
+ this.sessionSubscribers.set(sessionId, sessionClients);
402
+ if (requiresRuntimeBridge) {
403
+ this.ensureRuntimeEventBridge(sessionId);
404
+ }
405
+ this.send(socket, {
406
+ type: 'response',
407
+ request_id: requestId,
408
+ session_id: sessionId,
409
+ data: { success: true, subscribed: topics },
410
+ });
411
+ }
412
+ async handleUnsubscribe(socket, payload) {
413
+ const requestId = String(payload.request_id || '');
414
+ const sessionId = String(payload.session_id || '');
415
+ const topics = Array.isArray(payload.data?.topics) ? payload.data.topics : [];
416
+ const clientTopics = this.subscriptions.get(socket);
417
+ if (!clientTopics) {
418
+ this.send(socket, {
419
+ type: 'response',
420
+ request_id: requestId,
421
+ session_id: sessionId,
422
+ data: { success: true, unsubscribed: [] },
423
+ });
424
+ return;
425
+ }
426
+ topics.forEach((topic) => clientTopics.delete(topic));
427
+ if (clientTopics.size === 0) {
428
+ this.subscriptions.delete(socket);
429
+ }
430
+ this.send(socket, {
431
+ type: 'response',
432
+ request_id: requestId,
433
+ session_id: sessionId,
434
+ data: { success: true, unsubscribed: topics },
435
+ });
436
+ }
437
+ handleSocketClose(socket) {
438
+ this.subscriptions.delete(socket);
439
+ for (const [sessionId, clients] of this.sessionSubscribers.entries()) {
440
+ clients.delete(socket);
441
+ if (clients.size === 0) {
442
+ this.sessionSubscribers.delete(sessionId);
443
+ }
444
+ }
445
+ }
446
+ broadcastEvent(topic, sessionId, data) {
447
+ const clients = this.sessionSubscribers.get(sessionId);
448
+ if (!clients)
449
+ return;
450
+ const payload = {
451
+ type: 'event',
452
+ topic,
453
+ session_id: sessionId,
454
+ data,
455
+ };
456
+ logDebug('browser-service', 'runtimeEvent:broadcast', { topic, sessionId, listeners: clients.size });
457
+ clients.forEach((socket) => {
458
+ const clientTopics = this.subscriptions.get(socket);
459
+ if (clientTopics?.has(topic)) {
460
+ try {
461
+ socket.send(JSON.stringify(payload));
462
+ }
463
+ catch (err) {
464
+ console.warn('[browser-ws] failed to broadcast event:', err);
465
+ }
466
+ }
467
+ });
468
+ }
469
+ ensureRuntimeEventBridge(sessionId) {
470
+ if (this.runtimeBridgeUnsub.has(sessionId))
471
+ return;
472
+ const session = this.options.sessionManager.getSession(sessionId);
473
+ if (!session)
474
+ return;
475
+ const unsub = session.addRuntimeEventObserver((event) => {
476
+ const data = { event, received_at: Date.now() };
477
+ this.broadcastEvent('browser.runtime.event', sessionId, data);
478
+ if (event?.type) {
479
+ this.broadcastEvent(this.formatRuntimeTopic(event.type), sessionId, data);
480
+ }
481
+ });
482
+ this.runtimeBridgeUnsub.set(sessionId, unsub);
483
+ }
484
+ teardownRuntimeEventBridge(sessionId) {
485
+ const unsub = this.runtimeBridgeUnsub.get(sessionId);
486
+ if (unsub) {
487
+ try {
488
+ unsub();
489
+ }
490
+ catch { }
491
+ this.runtimeBridgeUnsub.delete(sessionId);
492
+ }
493
+ }
494
+ formatRuntimeTopic(type) {
495
+ const safe = (type || 'unknown').replace(/[^a-zA-Z0-9_.-]/g, '_').toLowerCase();
496
+ return `browser.runtime.event.${safe}`;
497
+ }
498
+ async handlePageControl(sessionId, command) {
499
+ const action = command.action;
500
+ const parameters = command.parameters || {};
501
+ if (!sessionId)
502
+ throw new Error('session_id required');
503
+ const session = this.options.sessionManager.getSession(sessionId);
504
+ if (!session) {
505
+ return { success: false, error: `Session ${sessionId} not found` };
506
+ }
507
+ if (action === 'navigate') {
508
+ const url = parameters.url;
509
+ if (!url)
510
+ throw new Error('navigate requires url');
511
+ await session.goto(url);
512
+ return { success: true, data: { action: 'navigated', url } };
513
+ }
514
+ if (action === 'screenshot') {
515
+ const filename = parameters.filename || `screenshot_${Date.now()}.png`;
516
+ const fullPage = parameters.full_page !== false;
517
+ const dir = path.resolve(process.cwd(), 'screenshots');
518
+ await fs.promises.mkdir(dir, { recursive: true });
519
+ const target = path.join(dir, filename);
520
+ const buffer = await session.screenshot(fullPage);
521
+ await fs.promises.writeFile(target, buffer);
522
+ return { success: true, data: { action: 'screenshot', screenshot_path: target, full_page: fullPage } };
523
+ }
524
+ throw new Error(`Unknown page_control action: ${action}`);
525
+ }
526
+ async handleDomOperation(sessionId, command) {
527
+ const action = command.action;
528
+ const parameters = command.parameters || {};
529
+ if (action === 'pick_dom') {
530
+ const session = this.options.sessionManager.getSession(sessionId);
531
+ if (!session) {
532
+ return { success: false, error: `Session ${sessionId} not found` };
533
+ }
534
+ return this.handleDomPick(session, parameters);
535
+ }
536
+ if (action === 'query') {
537
+ return this.handleNodeExecute(sessionId, { command_type: 'node_execute', node_type: 'query', parameters });
538
+ }
539
+ if (action === 'dom_full') {
540
+ return this.handleDomFull(sessionId, parameters);
541
+ }
542
+ if (action === 'dom_branch') {
543
+ return this.handleDomBranch(sessionId, parameters);
544
+ }
545
+ throw new Error(`Unknown dom_operation action: ${action}`);
546
+ }
547
+ async handleDomFull(sessionId, parameters) {
548
+ const session = this.options.sessionManager.getSession(sessionId);
549
+ if (!session) {
550
+ return { success: false, error: 'Session ' + sessionId + ' not found' };
551
+ }
552
+ const page = await session.ensurePage();
553
+ const rootSelector = parameters.root_selector || parameters.rootSelector || null;
554
+ const maxDepth = Number(parameters.max_depth || parameters.maxDepth || 8);
555
+ await ensurePageRuntime(page);
556
+ const domTree = await page.evaluate((config) => {
557
+ const runtime = window.__camoRuntime;
558
+ if (!runtime?.getDomBranch) {
559
+ throw new Error('runtime.getDomBranch unavailable');
560
+ }
561
+ return runtime.getDomBranch('root', {
562
+ rootSelector: config.rootSelector,
563
+ maxDepth: config.maxDepth,
564
+ maxChildren: 100,
565
+ });
566
+ }, { rootSelector, maxDepth });
567
+ const node = domTree.node || {};
568
+ const nodeCount = node.childCount || 0;
569
+ this.broadcastEvent('dom.updated', sessionId, {
570
+ root_path: domTree.path || 'root',
571
+ node_count: nodeCount,
572
+ });
573
+ return {
574
+ success: true,
575
+ data: {
576
+ root_path: domTree.path || 'root',
577
+ node_count: nodeCount,
578
+ snapshot: node,
579
+ },
580
+ };
581
+ }
582
+ async handleDomBranch(sessionId, parameters) {
583
+ const session = this.options.sessionManager.getSession(sessionId);
584
+ if (!session) {
585
+ return { success: false, error: 'Session ' + sessionId + ' not found' };
586
+ }
587
+ const page = await session.ensurePage();
588
+ const domPath = String(parameters.dom_path || parameters.domPath || '');
589
+ const depth = Number(parameters.depth || 3);
590
+ const rootSelector = parameters.root_selector || parameters.rootSelector || null;
591
+ await ensurePageRuntime(page);
592
+ const snapshot = await page.evaluate((config) => {
593
+ const runtime = window.__camoRuntime;
594
+ if (!runtime?.getDomBranch) {
595
+ throw new Error('runtime.getDomBranch unavailable');
596
+ }
597
+ return runtime.getDomBranch(config.domPath, {
598
+ rootSelector: config.rootSelector,
599
+ maxDepth: config.depth,
600
+ maxChildren: 100,
601
+ });
602
+ }, { domPath, depth, rootSelector });
603
+ const node = snapshot.node || {};
604
+ return {
605
+ success: true,
606
+ data: {
607
+ path: snapshot.path || domPath,
608
+ node_count: node.childCount || 0,
609
+ children: node.children || [],
610
+ },
611
+ };
612
+ }
613
+ async handleUserAction(sessionId, command) {
614
+ const action = command.action;
615
+ const parameters = command.parameters || {};
616
+ if (action !== 'operation') {
617
+ throw new Error(`Unknown user_action action: ${action}`);
618
+ }
619
+ const op = parameters.operation_type;
620
+ if (!op)
621
+ throw new Error('operation_type required');
622
+ if (op === 'click') {
623
+ return legacySelectorActionDisabled('user_action.operation', 'click');
624
+ }
625
+ if (op === 'type') {
626
+ return legacySelectorActionDisabled('user_action.operation', 'type');
627
+ }
628
+ if (op === 'scroll') {
629
+ const session = this.options.sessionManager.getSession(sessionId);
630
+ if (!session) {
631
+ return { success: false, error: `Session ${sessionId} not found` };
632
+ }
633
+ const page = await session.ensurePage();
634
+ const target = parameters.target || {};
635
+ const coordinates = target.coordinates || null;
636
+ const deltaY = Number(parameters.deltaY ?? target.deltaY ?? parameters.delta_y ?? target.delta_y ?? 0);
637
+ await page.mouse.wheel(0, deltaY);
638
+ this.broadcastEvent('user_action.completed', sessionId, {
639
+ action: 'scroll',
640
+ target: coordinates
641
+ ? `coordinates(${coordinates.x}, ${coordinates.y})`
642
+ : '',
643
+ duration_ms: 0,
644
+ deltaY,
645
+ });
646
+ return {
647
+ success: true,
648
+ data: {
649
+ action: 'scroll',
650
+ deltaY,
651
+ ...(coordinates ? { x: coordinates.x, y: coordinates.y } : {}),
652
+ },
653
+ };
654
+ }
655
+ if (op === 'move' || op === 'down' || op === 'up' || op === 'key') {
656
+ return this.handleExtendedUserAction(sessionId, op, parameters.target || {}, parameters);
657
+ }
658
+ throw new Error(`Unsupported operation_type: ${op}`);
659
+ }
660
+ async handleExtendedUserAction(sessionId, opType, target, params) {
661
+ const session = this.options.sessionManager.getSession(sessionId);
662
+ if (!session) {
663
+ return { success: false, error: 'Session ' + sessionId + ' not found' };
664
+ }
665
+ const page = await session.ensurePage();
666
+ const startedAt = Date.now();
667
+ const offset = target.offset || { x: 0, y: 0 };
668
+ const coordinates = target.coordinates || null;
669
+ const domPath = target.dom_path || null;
670
+ const selector = target.selector || null;
671
+ let coords = null;
672
+ // Support direct coordinates
673
+ if (coordinates && typeof coordinates.x === 'number' && typeof coordinates.y === 'number') {
674
+ coords = { x: coordinates.x + offset.x, y: coordinates.y + offset.y };
675
+ }
676
+ else if (domPath) {
677
+ await ensurePageRuntime(page);
678
+ const result = await page.evaluate((config) => {
679
+ const runtime = window.__camoRuntime;
680
+ if (!runtime?.dom?.resolveByPath)
681
+ return null;
682
+ const el = runtime.dom.resolveByPath(config.path, config.rootSelector);
683
+ if (!el)
684
+ return null;
685
+ const rect = el.getBoundingClientRect();
686
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
687
+ }, { path: domPath, rootSelector: null });
688
+ if (result) {
689
+ coords = { x: result.x + offset.x, y: result.y + offset.y };
690
+ }
691
+ }
692
+ else if (selector) {
693
+ const element = await page.$(selector);
694
+ if (element) {
695
+ const box = await element.boundingBox();
696
+ if (box) {
697
+ coords = { x: box.x + box.width / 2 + offset.x, y: box.y + box.height / 2 + offset.y };
698
+ }
699
+ }
700
+ }
701
+ if (opType === 'move') {
702
+ throw new Error('mouse:move disabled');
703
+ }
704
+ else if (opType === 'down') {
705
+ if (coords) {
706
+ await page.mouse.down();
707
+ }
708
+ }
709
+ else if (opType === 'up') {
710
+ await page.mouse.up();
711
+ }
712
+ else if (opType === 'key') {
713
+ const key = params.key || '';
714
+ if (key) {
715
+ await page.keyboard.press(key);
716
+ }
717
+ }
718
+ const duration = Date.now() - startedAt;
719
+ this.broadcastEvent('user_action.completed', sessionId, {
720
+ action: opType,
721
+ target: domPath || selector || (coordinates ? `coordinates(${coordinates.x}, ${coordinates.y})` : ''),
722
+ duration_ms: duration,
723
+ ...(coords ? { x: coords.x, y: coords.y } : {}),
724
+ });
725
+ return {
726
+ success: true,
727
+ data: {
728
+ action: opType,
729
+ target: domPath || selector || (coordinates ? `coordinates(${coordinates.x}, ${coordinates.y})` : ''),
730
+ duration_ms: duration,
731
+ },
732
+ };
733
+ }
734
+ async handleHighlight(sessionId, command) {
735
+ const action = command.action;
736
+ const parameters = command.parameters || {};
737
+ if (action === 'element') {
738
+ return this.handleDevCommand(sessionId, { command_type: 'dev_command', action: 'highlight_element', parameters });
739
+ }
740
+ if (action === 'dom_path') {
741
+ return this.handleDevCommand(sessionId, { command_type: 'dev_command', action: 'highlight_dom_path', parameters });
742
+ }
743
+ throw new Error(`Unknown highlight action: ${action}`);
744
+ }
745
+ async handleSessionControl(sessionId, command) {
746
+ const action = command.action;
747
+ if (action === 'create') {
748
+ const capabilities = command.capabilities || ['dom'];
749
+ const browserConfig = command.browser_config || {};
750
+ const profileId = browserConfig.profile_id || browserConfig.session_name || `session_${Date.now().toString(36)}`;
751
+ const headless = browserConfig.headless ?? false;
752
+ const viewport = browserConfig.viewport;
753
+ const userAgent = browserConfig.user_agent;
754
+ const initialUrl = browserConfig.initial_url || command.initial_url;
755
+ const result = await this.options.sessionManager.createSession({
756
+ profileId,
757
+ sessionName: browserConfig.session_name || profileId,
758
+ headless,
759
+ viewport,
760
+ userAgent,
761
+ initialUrl,
762
+ });
763
+ this.capabilityMap.set(result.sessionId, capabilities);
764
+ return {
765
+ success: true,
766
+ session_id: result.sessionId,
767
+ status: 'ready',
768
+ capabilities,
769
+ };
770
+ }
771
+ if (action === 'list') {
772
+ const sessions = this.options.sessionManager.listSessions().map((session) => ({
773
+ session_id: session.session_id || session.profileId,
774
+ profileId: session.profileId,
775
+ current_url: session.current_url,
776
+ mode: session.mode,
777
+ status: 'ready',
778
+ capabilities: this.capabilityMap.get(session.profileId) || [],
779
+ }));
780
+ return {
781
+ success: true,
782
+ sessions,
783
+ };
784
+ }
785
+ if (action === 'info') {
786
+ if (!sessionId) {
787
+ throw new Error('session_id required for info action');
788
+ }
789
+ const info = await this.options.sessionManager.getSessionInfo(sessionId);
790
+ if (!info) {
791
+ return {
792
+ success: false,
793
+ error: `Session ${sessionId} not found`,
794
+ };
795
+ }
796
+ return {
797
+ success: true,
798
+ session_info: {
799
+ ...info,
800
+ capabilities: this.capabilityMap.get(sessionId) || [],
801
+ },
802
+ };
803
+ }
804
+ if (action === 'delete') {
805
+ if (!sessionId) {
806
+ throw new Error('session_id required for delete action');
807
+ }
808
+ const deleted = await this.options.sessionManager.deleteSession(sessionId);
809
+ this.capabilityMap.delete(sessionId);
810
+ return {
811
+ success: deleted,
812
+ session_id: sessionId,
813
+ };
814
+ }
815
+ throw new Error(`Unknown session action: ${action}`);
816
+ }
817
+ async handleModeSwitch(sessionId, command) {
818
+ if (!sessionId) {
819
+ throw new Error('session_id required for mode switch');
820
+ }
821
+ const session = this.options.sessionManager.getSession(sessionId);
822
+ if (!session) {
823
+ return {
824
+ success: false,
825
+ error: `Session ${sessionId} not found`,
826
+ };
827
+ }
828
+ const target = command.target_mode || 'dev';
829
+ session.setMode(target);
830
+ return {
831
+ success: true,
832
+ session_id: sessionId,
833
+ new_mode: target,
834
+ };
835
+ }
836
+ async handleContainerOperation(sessionId, command) {
837
+ if (!sessionId) {
838
+ throw new Error('session_id required for container operations');
839
+ }
840
+ const session = this.options.sessionManager.getSession(sessionId);
841
+ if (!session) {
842
+ return {
843
+ success: false,
844
+ error: `Session ${sessionId} not found`,
845
+ };
846
+ }
847
+ logDebug('browser-service', 'containerOperation', { sessionId, command });
848
+ const pageContext = command.page_context || {};
849
+ if (command.action === 'match_root') {
850
+ const match = await this.matcher.matchRoot(session, pageContext);
851
+ if (!match) {
852
+ return {
853
+ success: false,
854
+ error: 'No matching container found',
855
+ };
856
+ }
857
+ return {
858
+ success: true,
859
+ data: {
860
+ container: match.container,
861
+ selector: match.container?.matched_selector || match.match_details?.selector,
862
+ domPath: match.match_details?.dom_path || null,
863
+ match_details: match.match_details,
864
+ },
865
+ };
866
+ }
867
+ if (command.action === 'inspect_tree') {
868
+ const snapshot = await this.matcher.inspectTree(session, pageContext, command.parameters || {});
869
+ return {
870
+ success: true,
871
+ data: snapshot,
872
+ };
873
+ }
874
+ if (command.action === 'inspect_dom_branch') {
875
+ const branch = await this.matcher.inspectDomBranch(session, pageContext, command.parameters || {});
876
+ return {
877
+ success: true,
878
+ data: branch,
879
+ };
880
+ }
881
+ throw new Error(`Unsupported container action: ${command.action}`);
882
+ }
883
+ async handleNodeExecute(sessionId, command) {
884
+ if (!sessionId) {
885
+ throw new Error('session_id required for node_execute');
886
+ }
887
+ const session = this.options.sessionManager.getSession(sessionId);
888
+ if (!session) {
889
+ return {
890
+ success: false,
891
+ error: `Session ${sessionId} not found`,
892
+ };
893
+ }
894
+ const nodeType = command.node_type;
895
+ const parameters = command.parameters || {};
896
+ switch (nodeType) {
897
+ case 'navigate': {
898
+ const url = parameters.url;
899
+ if (!url) {
900
+ throw new Error('Navigate node requires url');
901
+ }
902
+ await session.goto(url);
903
+ return {
904
+ success: true,
905
+ data: {
906
+ action: 'navigated',
907
+ url,
908
+ },
909
+ };
910
+ }
911
+ case 'click': {
912
+ return legacySelectorActionDisabled('node_execute', 'click');
913
+ }
914
+ case 'type': {
915
+ return legacySelectorActionDisabled('node_execute', 'type');
916
+ }
917
+ case 'screenshot': {
918
+ const filename = parameters.filename || `screenshot_${Date.now()}.png`;
919
+ const fullPage = parameters.full_page !== false;
920
+ const dir = path.resolve(process.cwd(), 'screenshots');
921
+ await fs.promises.mkdir(dir, { recursive: true });
922
+ const target = path.join(dir, filename);
923
+ const buffer = await session.screenshot(fullPage);
924
+ await fs.promises.writeFile(target, buffer);
925
+ return {
926
+ success: true,
927
+ data: {
928
+ action: 'screenshot',
929
+ screenshot_path: target,
930
+ full_page: fullPage,
931
+ },
932
+ };
933
+ }
934
+ case 'query': {
935
+ const selector = parameters.selector;
936
+ if (!selector)
937
+ throw new Error('Query node requires selector');
938
+ const limit = Number(parameters.max_items || parameters.maxItems || 5);
939
+ const page = await session.ensurePage();
940
+ const result = await page.$$eval(selector, (els, lim) => {
941
+ const sample = [];
942
+ const max = Math.max(0, Number(lim) || 0);
943
+ for (let i = 0; i < Math.min(max, els.length); i++) {
944
+ const el = els[i];
945
+ sample.push({
946
+ tag: el.tagName,
947
+ id: el.id || null,
948
+ classes: Array.from(el.classList || []),
949
+ text: (el.textContent || '').trim().slice(0, 120),
950
+ });
951
+ }
952
+ return {
953
+ count: els.length,
954
+ sample,
955
+ };
956
+ }, limit);
957
+ return {
958
+ success: true,
959
+ data: {
960
+ selector,
961
+ count: result.count,
962
+ sample: result.sample,
963
+ },
964
+ };
965
+ }
966
+ case 'dom_info': {
967
+ const page = await session.ensurePage();
968
+ const info = await page.evaluate(() => {
969
+ const doc = document;
970
+ const html = doc.documentElement;
971
+ const body = doc.body;
972
+ const serialize = (el) => {
973
+ if (!el)
974
+ return null;
975
+ return {
976
+ tag: el.tagName,
977
+ id: el.id || null,
978
+ classes: Array.from(el.classList || []),
979
+ };
980
+ };
981
+ const firstChildren = (el, limit = 8) => {
982
+ if (!el || !el.children)
983
+ return [];
984
+ return Array.from(el.children)
985
+ .slice(0, limit)
986
+ .map((child) => serialize(child));
987
+ };
988
+ return {
989
+ html: serialize(html),
990
+ body: serialize(body),
991
+ app: serialize(doc.getElementById('app')),
992
+ appChildren: firstChildren(doc.getElementById('app')),
993
+ bodyChildren: firstChildren(body),
994
+ };
995
+ });
996
+ return {
997
+ success: true,
998
+ data: info,
999
+ };
1000
+ }
1001
+ case 'eval':
1002
+ case 'evaluate':
1003
+ case 'evaluate_js': {
1004
+ const expression = parameters.expression || parameters.script;
1005
+ if (!expression) {
1006
+ throw new Error('Eval node requires expression');
1007
+ }
1008
+ const arg = parameters.arg;
1009
+ const result = await session.evaluate(expression, arg);
1010
+ return {
1011
+ success: true,
1012
+ data: { result },
1013
+ };
1014
+ }
1015
+ case 'pick_dom': {
1016
+ const result = await this.handleDomPick(session, parameters);
1017
+ return {
1018
+ success: true,
1019
+ data: result,
1020
+ };
1021
+ }
1022
+ case 'dom_pick_loopback': {
1023
+ const result = await this.handleDomPickerLoopback(session, parameters);
1024
+ return {
1025
+ success: true,
1026
+ data: result,
1027
+ };
1028
+ }
1029
+ default:
1030
+ throw new Error(`Unsupported node type: ${nodeType}`);
1031
+ }
1032
+ }
1033
+ async handleDevControl(sessionId, command) {
1034
+ if (command.action === 'enable_overlay') {
1035
+ return {
1036
+ success: true,
1037
+ data: {
1038
+ enabled: true,
1039
+ message: 'Overlay not implemented in TS service yet',
1040
+ },
1041
+ };
1042
+ }
1043
+ return {
1044
+ success: false,
1045
+ error: `Unsupported dev control action: ${command.action}`,
1046
+ };
1047
+ }
1048
+ async handleDevCommand(sessionId, command) {
1049
+ if (!sessionId) {
1050
+ throw new Error('session_id required for dev_command');
1051
+ }
1052
+ const session = this.options.sessionManager.getSession(sessionId);
1053
+ if (!session) {
1054
+ return {
1055
+ success: false,
1056
+ error: `Session ${sessionId} not found`,
1057
+ };
1058
+ }
1059
+ const action = command.action;
1060
+ const parameters = command.parameters || {};
1061
+ switch (action) {
1062
+ case 'highlight_element': {
1063
+ const selector = (parameters.selector || '').trim();
1064
+ if (!selector) {
1065
+ return { success: false, error: 'selector required' };
1066
+ }
1067
+ const channel = (parameters.channel || 'ui-action').trim() || 'ui-action';
1068
+ const style = typeof parameters.style === 'string' ? parameters.style : undefined;
1069
+ const duration = typeof parameters.duration === 'number' ? parameters.duration : Number(parameters.duration || 0);
1070
+ const sticky = typeof parameters.sticky === 'boolean' ? parameters.sticky : Boolean(parameters.hold || false);
1071
+ const rootSelector = parameters.root_selector || parameters.rootSelector || null;
1072
+ appendHighlightLog('request', { sessionId, channel, selector, style, duration, sticky, rootSelector });
1073
+ const page = await session.ensurePage();
1074
+ const result = await page.evaluate((config) => {
1075
+ if (!window.__camoRuntime?.highlight?.highlightSelector) {
1076
+ throw new Error('highlight runtime unavailable');
1077
+ }
1078
+ const res = window.__camoRuntime.highlight.highlightSelector(config.selector, {
1079
+ channel: config.channel,
1080
+ ...(config.style ? { style: config.style } : {}),
1081
+ ...(Number.isFinite(config.duration) && config.duration > 0 ? { duration: config.duration } : {}),
1082
+ ...(typeof config.sticky === 'boolean' ? { sticky: config.sticky } : {}),
1083
+ ...(config.rootSelector ? { rootSelector: config.rootSelector } : {}),
1084
+ });
1085
+ const count = typeof res === 'number' ? res : Number(res?.count || res?.matched || 0);
1086
+ return { count: Number.isFinite(count) ? count : 0, channel: config.channel };
1087
+ }, { selector, channel, style, duration: Number.isFinite(duration) ? duration : 0, sticky, rootSelector });
1088
+ appendHighlightLog('result', { sessionId, channel, selector, count: result?.count || 0 });
1089
+ return { success: true, data: result };
1090
+ }
1091
+ case 'clear_highlight': {
1092
+ const channel = (parameters.channel || 'ui-action').trim() || 'ui-action';
1093
+ appendHighlightLog('clear', { sessionId, channel });
1094
+ const page = await session.ensurePage();
1095
+ await page.evaluate((ch) => {
1096
+ window.__camoRuntime?.highlight?.clear?.(ch);
1097
+ }, channel);
1098
+ return {
1099
+ success: true,
1100
+ data: { cleared: true },
1101
+ };
1102
+ }
1103
+ case 'highlight_dom_path': {
1104
+ const path = (parameters.path || parameters.dom_path || '').trim();
1105
+ if (!path) {
1106
+ return { success: false, error: 'path required' };
1107
+ }
1108
+ const channel = (parameters.channel || 'ui-action').trim() || 'ui-action';
1109
+ const style = typeof parameters.style === 'string' ? parameters.style : undefined;
1110
+ const duration = typeof parameters.duration === 'number' ? parameters.duration : Number(parameters.duration || 0);
1111
+ const sticky = typeof parameters.sticky === 'boolean' ? parameters.sticky : Boolean(parameters.hold || false);
1112
+ const rootSelector = parameters.root_selector || parameters.rootSelector || null;
1113
+ appendHighlightLog('request', { sessionId, channel, path, style, duration, sticky, rootSelector });
1114
+ const page = await session.ensurePage();
1115
+ const result = await page.evaluate((config) => {
1116
+ const runtime = window.__camoRuntime;
1117
+ if (!runtime?.highlight?.highlightElements) {
1118
+ throw new Error('highlight runtime unavailable');
1119
+ }
1120
+ if (!runtime?.dom?.resolveByPath) {
1121
+ throw new Error('dom resolveByPath unavailable');
1122
+ }
1123
+ const node = runtime.dom.resolveByPath(config.path, config.rootSelector || null);
1124
+ if (!node)
1125
+ return { count: 0, channel: config.channel };
1126
+ runtime.highlight.highlightElements([node], {
1127
+ channel: config.channel,
1128
+ ...(config.style ? { style: config.style } : {}),
1129
+ ...(Number.isFinite(config.duration) && config.duration > 0 ? { duration: config.duration } : {}),
1130
+ ...(typeof config.sticky === 'boolean' ? { sticky: config.sticky } : {}),
1131
+ ...(config.rootSelector ? { rootSelector: config.rootSelector } : {}),
1132
+ });
1133
+ return { count: 1, channel: config.channel };
1134
+ }, { path, channel, style, duration: Number.isFinite(duration) ? duration : 0, sticky, rootSelector });
1135
+ appendHighlightLog('result', { sessionId, channel, path, count: result?.count || 0 });
1136
+ return { success: true, data: result };
1137
+ }
1138
+ case 'cancel_dom_pick': {
1139
+ const result = await this.cancelDomPicker(session);
1140
+ return {
1141
+ success: true,
1142
+ data: result,
1143
+ };
1144
+ }
1145
+ default:
1146
+ return {
1147
+ success: false,
1148
+ error: `Unsupported dev command: ${action}`,
1149
+ };
1150
+ }
1151
+ }
1152
+ async highlightViaRuntime() {
1153
+ return { count: 0 };
1154
+ }
1155
+ async clearHighlightOverlays() {
1156
+ return { cleared: 0 };
1157
+ }
1158
+ async cancelDomPicker(session) {
1159
+ const page = await session.ensurePage();
1160
+ return page.evaluate(() => {
1161
+ const cancel = window.__camoDomPickerCancel;
1162
+ if (typeof cancel === 'function') {
1163
+ try {
1164
+ cancel();
1165
+ window.__camoDomPickerCancel = null;
1166
+ return { cancelled: true };
1167
+ }
1168
+ catch (err) {
1169
+ return { cancelled: false, error: err.message };
1170
+ }
1171
+ }
1172
+ return { cancelled: false };
1173
+ });
1174
+ }
1175
+ send(socket, payload) {
1176
+ try {
1177
+ socket.send(JSON.stringify(payload));
1178
+ }
1179
+ catch (err) {
1180
+ console.error('[browser-ws] failed to send message:', err);
1181
+ }
1182
+ }
1183
+ rawToString(data) {
1184
+ if (typeof data === 'string')
1185
+ return data;
1186
+ if (Buffer.isBuffer(data))
1187
+ return data.toString('utf-8');
1188
+ if (Array.isArray(data)) {
1189
+ return Buffer.concat(data).toString('utf-8');
1190
+ }
1191
+ return Buffer.from(data).toString('utf-8');
1192
+ }
1193
+ }
1194
+ //# sourceMappingURL=ws-server.js.map