@restregular/dsh-visual-browser 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -21
- package/client.js +1077 -437
- package/lib/browser.js +1255 -963
- package/lib/cdp.js +208 -192
- package/lib/routes.js +101 -47
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +6 -6
- package/test/format.test.mjs +62 -0
- package/test/package.test.mjs +68 -0
- package/test/routes.test.mjs +248 -0
- package/test/scripts.test.mjs +214 -0
package/lib/cdp.js
CHANGED
|
@@ -1,192 +1,208 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal Chrome DevTools Protocol client.
|
|
3
|
-
*
|
|
4
|
-
* The plugin ships without dependencies: Node.js 22+ provides a WHATWG
|
|
5
|
-
* `WebSocket`, which is all a flattened CDP session needs. Every command is a
|
|
6
|
-
* JSON message with an `id`; flattened target sessions add `sessionId` to the
|
|
7
|
-
* command and echo it on the matching response.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** A CDP failure with a stable code so tools and routes can report a reason. */
|
|
11
|
-
export class CdpError extends Error {
|
|
12
|
-
constructor(code, message) {
|
|
13
|
-
super(message);
|
|
14
|
-
this.name = 'CdpError';
|
|
15
|
-
this.code = code;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const OPEN_TIMEOUT_MS = 15_000;
|
|
20
|
-
const COMMAND_TIMEOUT_MS = 30_000;
|
|
21
|
-
|
|
22
|
-
export class CdpConnection {
|
|
23
|
-
#socket;
|
|
24
|
-
#nextId = 1;
|
|
25
|
-
#pending = new Map();
|
|
26
|
-
#listeners = new Map();
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
socket
|
|
32
|
-
socket.addEventListener('
|
|
33
|
-
socket.addEventListener('
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
socket.removeEventListener('
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
socket.addEventListener('
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
bucket.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
pending.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Chrome DevTools Protocol client.
|
|
3
|
+
*
|
|
4
|
+
* The plugin ships without dependencies: Node.js 22+ provides a WHATWG
|
|
5
|
+
* `WebSocket`, which is all a flattened CDP session needs. Every command is a
|
|
6
|
+
* JSON message with an `id`; flattened target sessions add `sessionId` to the
|
|
7
|
+
* command and echo it on the matching response.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A CDP failure with a stable code so tools and routes can report a reason. */
|
|
11
|
+
export class CdpError extends Error {
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'CdpError';
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const OPEN_TIMEOUT_MS = 15_000;
|
|
20
|
+
const COMMAND_TIMEOUT_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
export class CdpConnection {
|
|
23
|
+
#socket;
|
|
24
|
+
#nextId = 1;
|
|
25
|
+
#pending = new Map();
|
|
26
|
+
#listeners = new Map();
|
|
27
|
+
#closeListeners = new Set();
|
|
28
|
+
#closed = false;
|
|
29
|
+
|
|
30
|
+
constructor(socket) {
|
|
31
|
+
this.#socket = socket;
|
|
32
|
+
socket.addEventListener('message', (event) => this.#onMessage(event.data));
|
|
33
|
+
socket.addEventListener('close', () => this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed')));
|
|
34
|
+
socket.addEventListener('error', () => {
|
|
35
|
+
/* The close event carries the outcome; keep this listener quiet. */
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Connect to a browser-level or page-level WebSocket endpoint. */
|
|
40
|
+
static async open(url, { timeoutMs = OPEN_TIMEOUT_MS } = {}) {
|
|
41
|
+
const Socket = globalThis.WebSocket;
|
|
42
|
+
if (typeof Socket !== 'function') {
|
|
43
|
+
throw new CdpError('CDP_UNAVAILABLE', 'this Node.js runtime has no built-in WebSocket (Node 22 or newer is required)');
|
|
44
|
+
}
|
|
45
|
+
const socket = new Socket(url);
|
|
46
|
+
await new Promise((resolve, reject) => {
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
cleanup();
|
|
49
|
+
try {
|
|
50
|
+
socket.close();
|
|
51
|
+
} catch {
|
|
52
|
+
/* already unusable */
|
|
53
|
+
}
|
|
54
|
+
reject(new CdpError('CDP_TIMEOUT', `timed out connecting to ${url}`));
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
const cleanup = () => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
socket.removeEventListener('open', onOpen);
|
|
59
|
+
socket.removeEventListener('error', onError);
|
|
60
|
+
};
|
|
61
|
+
const onOpen = () => {
|
|
62
|
+
cleanup();
|
|
63
|
+
resolve();
|
|
64
|
+
};
|
|
65
|
+
const onError = () => {
|
|
66
|
+
cleanup();
|
|
67
|
+
reject(new CdpError('CDP_CONNECT_FAILED', `could not connect to ${url}`));
|
|
68
|
+
};
|
|
69
|
+
socket.addEventListener('open', onOpen);
|
|
70
|
+
socket.addEventListener('error', onError);
|
|
71
|
+
});
|
|
72
|
+
return new CdpConnection(socket);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get closed() {
|
|
76
|
+
return this.#closed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Send one protocol command; `sessionId` targets a flattened session. */
|
|
80
|
+
send(method, params = {}, sessionId) {
|
|
81
|
+
if (this.#closed) return Promise.reject(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
|
|
82
|
+
const id = this.#nextId++;
|
|
83
|
+
const payload = { id, method, params };
|
|
84
|
+
if (sessionId !== undefined) payload.sessionId = sessionId;
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
this.#pending.delete(id);
|
|
88
|
+
reject(new CdpError('CDP_COMMAND_TIMEOUT', `${method} did not answer within ${COMMAND_TIMEOUT_MS}ms`));
|
|
89
|
+
}, COMMAND_TIMEOUT_MS);
|
|
90
|
+
this.#pending.set(id, { resolve, reject, method, timer });
|
|
91
|
+
try {
|
|
92
|
+
this.#socket.send(JSON.stringify(payload));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
this.#pending.delete(id);
|
|
96
|
+
reject(new CdpError('CDP_SEND_FAILED', messageOf(error)));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Subscribe to one protocol event; the returned function unsubscribes. */
|
|
102
|
+
on(method, listener) {
|
|
103
|
+
const bucket = this.#listeners.get(method) ?? new Set();
|
|
104
|
+
bucket.add(listener);
|
|
105
|
+
this.#listeners.set(method, bucket);
|
|
106
|
+
return () => {
|
|
107
|
+
bucket.delete(listener);
|
|
108
|
+
if (bucket.size === 0) this.#listeners.delete(method);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Resolve on the next `method` event, or reject on timeout. */
|
|
113
|
+
once(method, timeoutMs) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
off();
|
|
117
|
+
reject(new CdpError('CDP_EVENT_TIMEOUT', `waited ${timeoutMs}ms for ${method}`));
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
const off = this.on(method, (params, sessionId) => {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
off();
|
|
122
|
+
resolve({ params, sessionId });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Notified once with the failure that closed the socket. */
|
|
128
|
+
onClose(listener) {
|
|
129
|
+
this.#closeListeners.add(listener);
|
|
130
|
+
return () => this.#closeListeners.delete(listener);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
close() {
|
|
134
|
+
if (this.#closed) return;
|
|
135
|
+
this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
|
|
136
|
+
try {
|
|
137
|
+
this.#socket.close();
|
|
138
|
+
} catch {
|
|
139
|
+
/* already closed */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#onMessage(raw) {
|
|
144
|
+
let message;
|
|
145
|
+
try {
|
|
146
|
+
message = JSON.parse(typeof raw === 'string' ? raw : String(raw));
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (message && typeof message.id === 'number') {
|
|
151
|
+
const pending = this.#pending.get(message.id);
|
|
152
|
+
if (pending === undefined) return;
|
|
153
|
+
this.#pending.delete(message.id);
|
|
154
|
+
clearTimeout(pending.timer);
|
|
155
|
+
if (message.error) {
|
|
156
|
+
pending.reject(
|
|
157
|
+
new CdpError(
|
|
158
|
+
`CDP_${String(message.error.code ?? 'ERROR')}`,
|
|
159
|
+
`${pending.method}: ${message.error.message ?? 'protocol error'}`,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
pending.resolve(message.result);
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!message || typeof message.method !== 'string') return;
|
|
168
|
+
const bucket = this.#listeners.get(message.method);
|
|
169
|
+
if (bucket === undefined) return;
|
|
170
|
+
for (const listener of [...bucket]) {
|
|
171
|
+
try {
|
|
172
|
+
listener(message.params, message.sessionId);
|
|
173
|
+
} catch {
|
|
174
|
+
/* a failing listener must not break the socket */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#failAll(error) {
|
|
180
|
+
if (this.#closed) return;
|
|
181
|
+
this.#closed = true;
|
|
182
|
+
for (const [id, pending] of this.#pending) {
|
|
183
|
+
clearTimeout(pending.timer);
|
|
184
|
+
this.#pending.delete(id);
|
|
185
|
+
pending.reject(error);
|
|
186
|
+
}
|
|
187
|
+
this.#listeners.clear();
|
|
188
|
+
const closeListeners = [...this.#closeListeners];
|
|
189
|
+
this.#closeListeners.clear();
|
|
190
|
+
for (const listener of closeListeners) {
|
|
191
|
+
try {
|
|
192
|
+
listener(error);
|
|
193
|
+
} catch {
|
|
194
|
+
/* a close listener must not block teardown */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function messageOf(error) {
|
|
201
|
+
if (error instanceof Error) return error.message;
|
|
202
|
+
if (typeof error === 'string') return error;
|
|
203
|
+
try {
|
|
204
|
+
return JSON.stringify(error);
|
|
205
|
+
} catch {
|
|
206
|
+
return String(error);
|
|
207
|
+
}
|
|
208
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Same-origin HTTP API behind the Visual Browser panel.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Three jobs: a tiny `/state` JSON poll for the operation log, one long-lived
|
|
5
|
+
* `/stream` response that carries Chrome's own screencast frames as a binary
|
|
6
|
+
* frame stream, and `/input` batches that go straight to the DevTools input
|
|
7
|
+
* domain. No screenshot per click, no per-frame request, no base64 hop.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { BrowserError } from './browser.js';
|
|
11
11
|
|
|
12
|
-
const BODY_LIMIT =
|
|
12
|
+
const BODY_LIMIT = 128 * 1024;
|
|
13
13
|
|
|
14
14
|
class HttpError extends Error {
|
|
15
15
|
constructor(status, code, message) {
|
|
@@ -28,17 +28,10 @@ function sendJson(res, status, payload) {
|
|
|
28
28
|
res.end(body);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
res.statusCode = 204;
|
|
34
|
-
res.end();
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
res.statusCode = 200;
|
|
38
|
-
res.setHeader('content-type', 'image/jpeg');
|
|
31
|
+
function sendEmpty(res, status) {
|
|
32
|
+
res.statusCode = status;
|
|
39
33
|
res.setHeader('cache-control', 'no-store');
|
|
40
|
-
res.
|
|
41
|
-
res.end(frame.jpeg);
|
|
34
|
+
res.end();
|
|
42
35
|
}
|
|
43
36
|
|
|
44
37
|
async function readJsonBody(req) {
|
|
@@ -75,6 +68,78 @@ function failure(res, error) {
|
|
|
75
68
|
sendJson(res, status, { ok: false, error: { code, message } });
|
|
76
69
|
}
|
|
77
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Hold the response open as one binary frame stream:
|
|
73
|
+
*
|
|
74
|
+
* [uint32 LE payload length][uint32 LE sequence][JPEG bytes]...
|
|
75
|
+
*
|
|
76
|
+
* The panel reads it with `fetch` streaming and decodes each JPEG with
|
|
77
|
+
* `createImageBitmap` onto a canvas. Parsing the framing ourselves keeps the
|
|
78
|
+
* picture deterministic across browser builds and skips the base64 hop an
|
|
79
|
+
* event stream would need.
|
|
80
|
+
*/
|
|
81
|
+
function openStream(manager, res) {
|
|
82
|
+
if (typeof manager.subscribeFrames !== 'function') {
|
|
83
|
+
sendEmpty(res, 503);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!manager.running) {
|
|
87
|
+
sendEmpty(res, 204);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
res.statusCode = 200;
|
|
91
|
+
// A custom type stays out of every compressible mime table on purpose: the
|
|
92
|
+
// host's gzip middleware must never buffer a live picture.
|
|
93
|
+
res.setHeader('content-type', 'application/x-dsh-visual-browser-frames');
|
|
94
|
+
// no-transform keeps the host's gzip middleware out of a live picture.
|
|
95
|
+
res.setHeader('cache-control', 'no-store, no-transform');
|
|
96
|
+
res.setHeader('pragma', 'no-cache');
|
|
97
|
+
res.setHeader('x-accel-buffering', 'no');
|
|
98
|
+
res.setHeader('connection', 'keep-alive');
|
|
99
|
+
res.flushHeaders?.();
|
|
100
|
+
|
|
101
|
+
let closed = false;
|
|
102
|
+
let paused = false;
|
|
103
|
+
let unsubscribe = null;
|
|
104
|
+
let sequence = 0;
|
|
105
|
+
|
|
106
|
+
const cleanup = () => {
|
|
107
|
+
if (closed) return;
|
|
108
|
+
closed = true;
|
|
109
|
+
try {
|
|
110
|
+
unsubscribe?.();
|
|
111
|
+
} catch {
|
|
112
|
+
/* the browser may be gone */
|
|
113
|
+
}
|
|
114
|
+
unsubscribe = null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const writeFrame = (jpeg) => {
|
|
118
|
+
if (closed || paused) return;
|
|
119
|
+
try {
|
|
120
|
+
const header = Buffer.allocUnsafe(8);
|
|
121
|
+
header.writeUInt32LE(jpeg.length, 0);
|
|
122
|
+
header.writeUInt32LE(++sequence, 4);
|
|
123
|
+
const headOk = res.write(header);
|
|
124
|
+
const bodyOk = res.write(jpeg);
|
|
125
|
+
if (!headOk || !bodyOk) {
|
|
126
|
+
// Slow consumer: drop frames until the socket drains instead of growing
|
|
127
|
+
// an unbounded queue of stale pictures.
|
|
128
|
+
paused = true;
|
|
129
|
+
res.once('drain', () => {
|
|
130
|
+
paused = false;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
cleanup();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
unsubscribe = manager.subscribeFrames(writeFrame);
|
|
139
|
+
res.on('close', cleanup);
|
|
140
|
+
res.on('error', cleanup);
|
|
141
|
+
}
|
|
142
|
+
|
|
78
143
|
/**
|
|
79
144
|
* Handle one request under `prefix`. Exported so a smoke test can drive it with
|
|
80
145
|
* a plain request/response pair and a stub manager.
|
|
@@ -90,7 +155,7 @@ export async function handle(manager, prefix, req, res) {
|
|
|
90
155
|
}
|
|
91
156
|
let route = url.pathname;
|
|
92
157
|
if (route.startsWith(prefix)) route = route.slice(prefix.length);
|
|
93
|
-
if (route === ''
|
|
158
|
+
if (route === '') route = '/';
|
|
94
159
|
|
|
95
160
|
try {
|
|
96
161
|
if (method === 'GET' && route === '/') {
|
|
@@ -101,15 +166,15 @@ export async function handle(manager, prefix, req, res) {
|
|
|
101
166
|
sendJson(res, 200, { ok: true, state: manager.state() });
|
|
102
167
|
return;
|
|
103
168
|
}
|
|
104
|
-
if (method === 'GET' && route === '/
|
|
105
|
-
|
|
106
|
-
sendFrame(res, manager.frameFor(seq));
|
|
169
|
+
if (method === 'GET' && route === '/stream') {
|
|
170
|
+
openStream(manager, res);
|
|
107
171
|
return;
|
|
108
172
|
}
|
|
109
|
-
if (method === '
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
173
|
+
if (method === 'POST' && route === '/input') {
|
|
174
|
+
const body = await readJsonBody(req);
|
|
175
|
+
const events = Array.isArray(body.events) ? body.events : [];
|
|
176
|
+
const result = await manager.dispatchInput(events);
|
|
177
|
+
sendJson(res, 200, { ok: true, applied: result?.applied ?? 0, state: manager.state() });
|
|
113
178
|
return;
|
|
114
179
|
}
|
|
115
180
|
if (method === 'POST' && route === '/start') {
|
|
@@ -117,6 +182,7 @@ export async function handle(manager, prefix, req, res) {
|
|
|
117
182
|
const target = typeof body.url === 'string' ? body.url.trim() : '';
|
|
118
183
|
if (target !== '') await manager.open(target);
|
|
119
184
|
else await manager.ensureStarted();
|
|
185
|
+
manager.refreshLiveView?.();
|
|
120
186
|
sendJson(res, 200, { ok: true, state: manager.state() });
|
|
121
187
|
return;
|
|
122
188
|
}
|
|
@@ -125,33 +191,12 @@ export async function handle(manager, prefix, req, res) {
|
|
|
125
191
|
sendJson(res, 200, { ok: true, state: manager.state() });
|
|
126
192
|
return;
|
|
127
193
|
}
|
|
128
|
-
if (method === 'POST' && route === '/capture') {
|
|
129
|
-
const state = await manager.refreshFrame();
|
|
130
|
-
sendJson(res, 200, { ok: true, state });
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
if (method === 'POST' && route === '/click') {
|
|
134
|
-
const body = await readJsonBody(req);
|
|
135
|
-
await manager.clickAt({ x: body.x, y: body.y });
|
|
136
|
-
sendJson(res, 200, { ok: true, state: manager.state() });
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (method === 'POST' && route === '/wheel') {
|
|
140
|
-
const body = await readJsonBody(req);
|
|
141
|
-
await manager.wheelAt({ deltaY: body.deltaY, deltaX: body.deltaX });
|
|
142
|
-
sendJson(res, 200, { ok: true, state: manager.state() });
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
if (method === 'POST' && route === '/key') {
|
|
146
|
-
const body = await readJsonBody(req);
|
|
147
|
-
await manager.keyFromUser(String(body.key ?? 'Enter'));
|
|
148
|
-
sendJson(res, 200, { ok: true, state: manager.state() });
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
194
|
if (method === 'POST' && route === '/nav') {
|
|
152
195
|
const body = await readJsonBody(req);
|
|
153
196
|
const action = String(body.action ?? '');
|
|
154
|
-
if (!['back', 'forward', 'reload'].includes(action))
|
|
197
|
+
if (!['back', 'forward', 'reload'].includes(action)) {
|
|
198
|
+
throw new HttpError(400, 'BAD_ACTION', 'action must be back, forward, or reload');
|
|
199
|
+
}
|
|
155
200
|
await manager.act({ action });
|
|
156
201
|
sendJson(res, 200, { ok: true, state: manager.state() });
|
|
157
202
|
return;
|
|
@@ -159,6 +204,15 @@ export async function handle(manager, prefix, req, res) {
|
|
|
159
204
|
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `No route for ${method} ${route}` } });
|
|
160
205
|
} catch (error) {
|
|
161
206
|
manager.noteError?.(error);
|
|
207
|
+
if (res.headersSent) {
|
|
208
|
+
// A stream that failed after its headers went out can only be torn down.
|
|
209
|
+
try {
|
|
210
|
+
res.end();
|
|
211
|
+
} catch {
|
|
212
|
+
/* already closed */
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
162
216
|
failure(res, error);
|
|
163
217
|
}
|
|
164
218
|
}
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Visual Browser",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "One extra door under the right Sidebar's built-in Browser: a live picture of the Chrome the Agent drives, with real click, drag, wheel and keyboard control, plus a collapsible, scrollable action log — no screenshot polling, no frame replay."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "可视化浏览器",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "在右侧边栏「浏览器」下面多一个入口:实时映射 Agent 正在操作的 Chrome 画面,点击、拖动、滚轮、键盘都能直接操作,操作记录可折叠、可滚动,不再需要逐帧截图或回放。"
|
|
5
5
|
}
|
|
6
6
|
}
|