@activeagents/browser-sidecar 0.0.0-stage → 1.9.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.
@@ -0,0 +1,210 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ // The Playwright MCP tools that only look at the browser. Every other tool
4
+ // changes the page, and waits while a person holds control.
5
+ export const OBSERVING_TOOLS = new Set([
6
+ 'browser_snapshot',
7
+ 'browser_take_screenshot',
8
+ 'browser_console_messages',
9
+ 'browser_network_requests',
10
+ 'browser_network_request',
11
+ 'browser_find',
12
+ 'browser_generate_locator',
13
+ 'browser_wait_for',
14
+ 'browser_verify_element_visible',
15
+ 'browser_verify_list_visible',
16
+ 'browser_verify_text_visible',
17
+ 'browser_verify_value',
18
+ 'browser_pdf_save',
19
+ 'browser_get_config',
20
+ ]);
21
+
22
+ // The _meta key a refused call's result names the person driving under.
23
+ export const TAKEOVER_META = 'activeagents/takeover';
24
+
25
+ /**
26
+ * Whether the tool call `name` with `args` would change what the browser
27
+ * shows.
28
+ *
29
+ * @param {string} name
30
+ * @param {object} [args]
31
+ */
32
+ export function changesPage(name, args = {}) {
33
+ if (name === 'browser_tabs') return args?.action !== 'list';
34
+ return !OBSERVING_TOOLS.has(name);
35
+ }
36
+
37
+ /**
38
+ * Whether the holders' users `a` and `b` are the same person. A user with no
39
+ * id is nobody in particular, so is never the same as anyone.
40
+ *
41
+ * @param {{ id: string|null }} a
42
+ * @param {{ id: string|null }} b
43
+ */
44
+ export function sameUser(a, b) {
45
+ return a.id !== null && a.id === b.id;
46
+ }
47
+
48
+ /**
49
+ * Used to let one person at a time drive the browser by hand. While someone
50
+ * holds control, an agent's call that changes the page waits for them to
51
+ * hand it back (#admit).
52
+ *
53
+ * A holder is identified by the key of their connection, and is a
54
+ * `{ key, user: { id, name }, since }`. A user with an id may move control
55
+ * between their own connections: a second tab, or a reconnect.
56
+ *
57
+ * Events:
58
+ * change (holder, previous, reason) whenever the holder changes;
59
+ * reason is "taken", "moved", "handed_back", "disconnected"
60
+ * or "closed"
61
+ * agent_waiting ({ tool, since }) an agent's call began to wait
62
+ * agent_done ({ tool, admitted }) it stopped waiting, run or refused
63
+ */
64
+ export class ControlLock extends EventEmitter {
65
+ /**
66
+ * @param {object} [options]
67
+ * @param {number} [options.graceMs] how long control stays with a holder whose connection dropped
68
+ * @param {() => number} [options.now] epoch milliseconds
69
+ */
70
+ constructor({ graceMs = 10_000, now = Date.now } = {}) {
71
+ super();
72
+ this.graceMs = graceMs;
73
+ this.now = now;
74
+ this.holder = null;
75
+ this.graceTimer = null;
76
+ this.waiting = new Set();
77
+ this.closed = false;
78
+ }
79
+
80
+ /**
81
+ * Gives control to the connection `key`, for `user`, unless another
82
+ * person holds it.
83
+ *
84
+ * @param {string} key
85
+ * @param {{ id: string|null, name: string|null }} user
86
+ * @returns {{ holder: object, taken: boolean }} taken is false when someone else holds it
87
+ */
88
+ acquire(key, user) {
89
+ const previous = this.holder;
90
+ if (this.closed) return { holder: previous, taken: false };
91
+ if (previous?.key === key) return { holder: previous, taken: true };
92
+ if (previous && !sameUser(previous.user, user)) return { holder: previous, taken: false };
93
+
94
+ this.clearGrace();
95
+ this.holder = { key, user, since: previous?.since ?? this.now() };
96
+ this.emit('change', this.holder, previous, previous ? 'moved' : 'taken');
97
+ return { holder: this.holder, taken: true };
98
+ }
99
+
100
+ /**
101
+ * Takes control from the connection `key`, when it holds it.
102
+ *
103
+ * @param {string} key
104
+ * @param {string} [reason]
105
+ * @returns {boolean} whether it held control
106
+ */
107
+ release(key, reason = 'handed_back') {
108
+ if (this.holder?.key !== key) return false;
109
+
110
+ this.clearGrace();
111
+ const previous = this.holder;
112
+ this.holder = null;
113
+ this.emit('change', null, previous, reason);
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Notes that the connection `key` closed: when it holds control, control
119
+ * is released after the grace period, unless its user takes it up again
120
+ * from another connection first.
121
+ *
122
+ * @param {string} key
123
+ */
124
+ disconnected(key) {
125
+ if (this.holder?.key !== key) return;
126
+
127
+ this.clearGrace();
128
+ this.graceTimer = setTimeout(() => this.release(key, 'disconnected'), this.graceMs);
129
+ this.graceTimer.unref?.();
130
+ }
131
+
132
+ /**
133
+ * Lets an agent's tool call through, at once when it does not change the
134
+ * page or nobody holds control, and otherwise once control is handed back
135
+ * within `waitMs`.
136
+ *
137
+ * @param {string} name the tool
138
+ * @param {object} args its arguments
139
+ * @param {number} waitMs
140
+ * @returns {Promise<{ holder: object }|{ stopping: true }|null>} null to run the call; otherwise why it
141
+ * is refused: the holder who kept control past `waitMs`, or `stopping` when the lock closed meanwhile
142
+ */
143
+ async admit(name, args, waitMs) {
144
+ if (!this.holder || !changesPage(name, args)) return null;
145
+
146
+ const wait = { tool: name, since: this.now() };
147
+ const deadline = wait.since + waitMs;
148
+ this.waiting.add(wait);
149
+ this.emit('agent_waiting', wait);
150
+ // Someone may take control again between its release and this call
151
+ // going ahead, so the wait goes on until control is free at that moment.
152
+ while (this.holder && !this.closed && deadline > this.now()) {
153
+ if (!(await this.waitUntilFree(deadline - this.now()))) break;
154
+ }
155
+ let refusal = null;
156
+ if (this.closed) refusal = { stopping: true };
157
+ else if (this.holder) refusal = { holder: this.holder };
158
+ this.waiting.delete(wait);
159
+ this.emit('agent_done', { tool: name, admitted: refusal === null });
160
+ return refusal;
161
+ }
162
+
163
+ /**
164
+ * Resolves true once nobody holds control, or false after `timeoutMs`.
165
+ *
166
+ * @param {number} timeoutMs
167
+ * @returns {Promise<boolean>}
168
+ */
169
+ waitUntilFree(timeoutMs) {
170
+ if (!this.holder) return Promise.resolve(true);
171
+ if (this.closed) return Promise.resolve(false);
172
+
173
+ return new Promise((resolve) => {
174
+ const finish = (free) => {
175
+ clearTimeout(timer);
176
+ this.off('change', onChange);
177
+ this.off('closed', onClosed);
178
+ resolve(free);
179
+ };
180
+ const onChange = (holder) => {
181
+ if (!holder) finish(true);
182
+ };
183
+ const onClosed = () => finish(false);
184
+ const timer = setTimeout(() => finish(false), timeoutMs);
185
+ this.on('change', onChange);
186
+ this.on('closed', onClosed);
187
+ });
188
+ }
189
+
190
+ // The longest-waiting agent call, or null.
191
+ get agentWaiting() {
192
+ const [first] = this.waiting;
193
+ return first ?? null;
194
+ }
195
+
196
+ // Releases control for good, refusing the agent calls still waiting: the
197
+ // browser is stopping.
198
+ close() {
199
+ if (this.closed) return;
200
+ this.closed = true;
201
+ this.emit('closed');
202
+ if (this.holder) this.release(this.holder.key, 'closed');
203
+ this.clearGrace();
204
+ }
205
+
206
+ clearGrace() {
207
+ if (this.graceTimer !== null) clearTimeout(this.graceTimer);
208
+ this.graceTimer = null;
209
+ }
210
+ }
@@ -0,0 +1,127 @@
1
+ import http from 'node:http';
2
+ import net from 'node:net';
3
+
4
+ // Headers that belong to one hop and are never passed across the proxy.
5
+ const HOP_BY_HOP = new Set([
6
+ 'connection', 'proxy-connection', 'keep-alive', 'proxy-authorization', 'proxy-authenticate', 'te', 'trailer', 'transfer-encoding', 'upgrade',
7
+ ]);
8
+
9
+ function endToEndHeaders(headers) {
10
+ const named = String(headers.connection ?? '').toLowerCase().split(',').map((name) => name.trim());
11
+ return Object.fromEntries(Object.entries(headers).filter(([name]) => !HOP_BY_HOP.has(name) && !named.includes(name)));
12
+ }
13
+
14
+ // "example.com:443" or "[::1]:8080" as a host and port, or null.
15
+ function authority(text) {
16
+ const match = /^(\[[^\]]+\]|[^:]+):(\d{1,5})$/.exec(text ?? '');
17
+ if (!match) return null;
18
+ const port = Number(match[2]);
19
+ return port > 0 && port < 65536 ? { hostname: match[1], port } : null;
20
+ }
21
+
22
+ function refuse(response, status, reason) {
23
+ response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
24
+ response.end(`Blocked by the sandbox browser: ${reason}\n`);
25
+ }
26
+
27
+ /**
28
+ * Starts the forward proxy every connection the browser opens goes through,
29
+ * and returns once it listens on loopback. Each plain HTTP request and each
30
+ * CONNECT tunnel (HTTPS and WebSockets) is checked with
31
+ * `policy.destination`, and the upstream connection is made to the address
32
+ * that check returned, never to a name resolved again. A refused request is
33
+ * answered 403; a refused tunnel is never opened.
34
+ *
35
+ * @param {object} options
36
+ * @param {{ destination(hostname: string, port: number): Promise<{ address: string | null, refusal: string | null }> }} options.policy
37
+ * @param {(message: string) => void} [options.log]
38
+ * @returns {Promise<{ url: string, close: () => Promise<void> }>}
39
+ */
40
+ export async function startEgressProxy({ policy, log = () => {} }) {
41
+ const agent = new http.Agent({ keepAlive: true });
42
+ const sockets = new Set();
43
+ const track = (socket) => {
44
+ sockets.add(socket);
45
+ socket.once('close', () => sockets.delete(socket));
46
+ };
47
+
48
+ const server = http.createServer(async (request, response) => {
49
+ let target;
50
+ try {
51
+ target = new URL(request.url);
52
+ } catch {
53
+ return refuse(response, 400, 'not a proxy request');
54
+ }
55
+ if (target.protocol !== 'http:') return refuse(response, 400, `${target.protocol} requests are not proxied`);
56
+
57
+ const port = Number(target.port || 80);
58
+ const { address, refusal } = await policy.destination(target.hostname, port);
59
+ if (refusal) {
60
+ log(`refused a request: ${refusal}`);
61
+ return refuse(response, 403, refusal);
62
+ }
63
+
64
+ const upstream = http.request({
65
+ host: address,
66
+ port,
67
+ method: request.method,
68
+ path: `${target.pathname}${target.search}`,
69
+ headers: endToEndHeaders(request.headers),
70
+ setHost: false,
71
+ agent,
72
+ });
73
+ upstream.on('response', (answer) => {
74
+ response.writeHead(answer.statusCode, answer.statusMessage, endToEndHeaders(answer.headers));
75
+ answer.pipe(response);
76
+ });
77
+ upstream.on('error', (error) => {
78
+ if (response.headersSent) response.destroy();
79
+ else refuse(response, 502, `${target.host} could not be reached (${error.code ?? error.message})`);
80
+ });
81
+ response.on('close', () => {
82
+ if (!response.writableFinished) upstream.destroy();
83
+ });
84
+ request.pipe(upstream);
85
+ });
86
+
87
+ server.on('connection', track);
88
+ server.on('connect', async (request, socket, head) => {
89
+ socket.on('error', () => {});
90
+ const requested = authority(request.url);
91
+ const { address, refusal } = requested
92
+ ? await policy.destination(requested.hostname, requested.port)
93
+ : { address: null, refusal: `${request.url} is not a host and port` };
94
+ if (refusal) {
95
+ log(`refused a connection: ${refusal}`);
96
+ socket.end('HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\nconnection: close\r\n\r\n');
97
+ return;
98
+ }
99
+
100
+ const upstream = net.connect(requested.port, address);
101
+ track(upstream);
102
+ upstream.on('error', () => socket.destroy());
103
+ socket.on('close', () => upstream.destroy());
104
+ upstream.once('connect', () => {
105
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
106
+ if (head.length > 0) upstream.write(head);
107
+ upstream.pipe(socket);
108
+ socket.pipe(upstream);
109
+ });
110
+ });
111
+
112
+ await new Promise((resolve, reject) => {
113
+ server.once('error', reject);
114
+ server.listen(0, '127.0.0.1', resolve);
115
+ });
116
+
117
+ return {
118
+ url: `http://127.0.0.1:${server.address().port}`,
119
+ close: async () => {
120
+ await new Promise((resolve) => {
121
+ server.close(() => resolve());
122
+ for (const socket of sockets) socket.destroy();
123
+ });
124
+ agent.destroy();
125
+ },
126
+ };
127
+ }
package/lib/guard.mjs ADDED
@@ -0,0 +1,86 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+
3
+ const LOOPBACK_NAMES = ['127.0.0.1', 'localhost', '[::1]'];
4
+
5
+ function hostName(host) {
6
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
7
+ }
8
+
9
+ /**
10
+ * Returns the Host header values a request to the sidecar may carry: the
11
+ * address it listens on and, when that is loopback or every interface, the
12
+ * loopback names, each with the port; and `extra` as given (the names the
13
+ * sidecar is reached by inside a container network).
14
+ *
15
+ * @param {string} host the listening address
16
+ * @param {number} port the listening port
17
+ * @param {string[]} extra Host values to accept as given
18
+ * @returns {Set<string>}
19
+ */
20
+ export function acceptedHosts(host, port, extra = []) {
21
+ const names = new Set([hostName(host.toLowerCase())]);
22
+ if (['127.0.0.1', 'localhost', '::1', '0.0.0.0', '::'].includes(host)) {
23
+ for (const name of LOOPBACK_NAMES) names.add(name);
24
+ }
25
+ names.delete('0.0.0.0');
26
+ names.delete('[::]');
27
+
28
+ const accepted = new Set([...names].map((name) => `${name}:${port}`));
29
+ for (const value of extra) accepted.add(value.toLowerCase());
30
+ return accepted;
31
+ }
32
+
33
+ function digest(value) {
34
+ return createHash('sha256').update(value).digest();
35
+ }
36
+
37
+ function bearerMatches(header, token) {
38
+ const match = /^Bearer\s+(\S+)\s*$/i.exec(header ?? '');
39
+ return match !== null && timingSafeEqual(digest(match[1]), digest(token));
40
+ }
41
+
42
+ /**
43
+ * Checks one HTTP request, or WebSocket upgrade, before anything else reads
44
+ * it. A request must name an accepted Host, which defeats DNS rebinding,
45
+ * carry no Origin unless that origin is allowed (a page in a browser always
46
+ * sends one), and carry the bearer token.
47
+ *
48
+ * @param {import('node:http').IncomingMessage} request
49
+ * @param {{ token: string, hosts: Set<string>, origins?: Set<string> }} rules
50
+ * @returns {{ status: number, message: string } | null} the refusal, or null to proceed
51
+ */
52
+ export function refusal(request, { token, hosts, origins = new Set() }) {
53
+ const host = (request.headers.host ?? '').toLowerCase();
54
+ if (!hosts.has(host)) return { status: 403, message: 'Host not allowed' };
55
+
56
+ const origin = request.headers.origin;
57
+ if (origin !== undefined && !origins.has(origin)) return { status: 403, message: 'Origin not allowed' };
58
+
59
+ if (!bearerMatches(request.headers.authorization, token)) return { status: 401, message: 'Missing or invalid bearer token' };
60
+
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Checks a WebSocket upgrade to the live view before it is accepted. The
66
+ * dashboard's page opens it, so it carries the dashboard's Origin, and it
67
+ * carries no token: a viewer proves who they are with the ticket in its
68
+ * first message (LiveServer). The request must name an accepted Host, come
69
+ * from one of `origins`, and carry no query, which keeps a ticket out of
70
+ * URLs and the logs that record them.
71
+ *
72
+ * @param {import('node:http').IncomingMessage} request
73
+ * @param {{ hosts: Set<string>, origins: Set<string> }} rules
74
+ * @returns {{ status: number, message: string } | null} the refusal, or null to proceed
75
+ */
76
+ export function liveRefusal(request, { hosts, origins }) {
77
+ const host = (request.headers.host ?? '').toLowerCase();
78
+ if (!hosts.has(host)) return { status: 403, message: 'Host not allowed' };
79
+
80
+ const origin = request.headers.origin;
81
+ if (typeof origin !== 'string' || !origins.has(origin.toLowerCase())) return { status: 403, message: 'Origin not allowed' };
82
+
83
+ if (request.url.includes('?')) return { status: 400, message: 'The live view takes no query' };
84
+
85
+ return null;
86
+ }
@@ -0,0 +1,124 @@
1
+ import http from 'node:http';
2
+
3
+ import { liveRefusal, refusal } from './guard.mjs';
4
+ import { LIVE_PATH } from './live-server.mjs';
5
+
6
+ // Larger than any tool call the engine sends.
7
+ export const MAX_BODY_BYTES = 4 * 1024 * 1024;
8
+
9
+ function respond(response, status, body, headers = {}) {
10
+ const text = body === undefined ? '' : JSON.stringify(body);
11
+ response.writeHead(status, {
12
+ 'content-type': 'application/json',
13
+ 'cache-control': 'no-store',
14
+ 'content-length': Buffer.byteLength(text),
15
+ ...headers,
16
+ });
17
+ response.end(text);
18
+ }
19
+
20
+ function readBody(request) {
21
+ return new Promise((resolve, reject) => {
22
+ const chunks = [];
23
+ let size = 0;
24
+ request.on('data', (chunk) => {
25
+ size += chunk.length;
26
+ if (size > MAX_BODY_BYTES) {
27
+ reject(Object.assign(new Error('The body is too large'), { status: 413 }));
28
+ request.destroy();
29
+ return;
30
+ }
31
+ chunks.push(chunk);
32
+ });
33
+ request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
34
+ request.on('error', reject);
35
+ });
36
+ }
37
+
38
+ function pathOf(request) {
39
+ try {
40
+ return new URL(request.url, 'http://sidecar').pathname;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function rejectUpgrade(socket, status) {
47
+ socket.end(`HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
48
+ }
49
+
50
+ /**
51
+ * Creates the sidecar's HTTP server:
52
+ *
53
+ * GET /health { status: "ok", version }
54
+ * POST /mcp one JSON-RPC message, answered as JSON (McpGateway)
55
+ * DELETE /mcp ends the session named by Mcp-Session-Id
56
+ * GET /live the live view's WebSocket (LiveServer), when `live` is given
57
+ * GET /storage-state the browser's cookies and localStorage for the
58
+ * app's origin, so a sign-in can be kept
59
+ *
60
+ * Every request, and every WebSocket upgrade other than the live view's, is
61
+ * checked by guard.refusal before anything else happens. The live view's
62
+ * upgrade is checked by guard.liveRefusal instead, and any other upgrade is
63
+ * refused.
64
+ *
65
+ * @param {object} options
66
+ * @param {() => { token: string, hosts: Set<string>, origins?: Set<string>, liveOrigins?: Set<string> }} options.rules
67
+ * what the guards check against, read per request since the port is known only once listening
68
+ * @param {{ handle: Function, closeSession: Function }} options.gateway
69
+ * @param {string} options.version
70
+ * @param {{ handleUpgrade: Function } | null} [options.live] a LiveServer
71
+ * @param {() => Promise<object>} [options.storageState] returns what GET /storage-state answers
72
+ * @returns {import('node:http').Server}
73
+ */
74
+ export function createHttpServer({ rules, gateway, version, live = null, storageState = null }) {
75
+ const server = http.createServer(async (request, response) => {
76
+ const denied = refusal(request, rules());
77
+ if (denied) return respond(response, denied.status, { error: denied.message });
78
+
79
+ const path = new URL(request.url, 'http://sidecar').pathname;
80
+ try {
81
+ if (path === '/health' && request.method === 'GET') return respond(response, 200, { status: 'ok', version });
82
+ if (path === '/storage-state' && request.method === 'GET' && storageState) {
83
+ return respond(response, 200, { storage_state: await storageState() });
84
+ }
85
+ if (path !== '/mcp') return respond(response, 404, { error: 'Not found' });
86
+
87
+ const sessionId = request.headers['mcp-session-id'];
88
+ if (request.method === 'DELETE') {
89
+ const closed = typeof sessionId === 'string' && (await gateway.closeSession(sessionId));
90
+ return respond(response, closed ? 200 : 404, closed ? { closed: true } : { error: 'Session not found' });
91
+ }
92
+ if (request.method !== 'POST') return respond(response, 405, { error: 'POST JSON-RPC here' }, { allow: 'POST, DELETE' });
93
+
94
+ let message;
95
+ try {
96
+ message = JSON.parse(await readBody(request));
97
+ } catch (error) {
98
+ if (error.status) throw error;
99
+ return respond(response, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'The body is not JSON' } });
100
+ }
101
+
102
+ const result = await gateway.handle(message, typeof sessionId === 'string' ? sessionId : undefined);
103
+ return respond(response, result.status, result.body, result.headers);
104
+ } catch (error) {
105
+ if (!response.headersSent) respond(response, error.status ?? 500, { error: error.status ? error.message : 'Internal error' });
106
+ if (!error.status) process.stderr.write(`browser-sidecar: ${error.stack ?? error}\n`);
107
+ }
108
+ });
109
+
110
+ server.on('upgrade', (request, socket, head) => {
111
+ const current = rules();
112
+ if (live && pathOf(request) === LIVE_PATH) {
113
+ const denied = liveRefusal(request, { hosts: current.hosts, origins: current.liveOrigins ?? new Set() });
114
+ if (denied) rejectUpgrade(socket, denied.status);
115
+ else live.handleUpgrade(request, socket, head);
116
+ return;
117
+ }
118
+
119
+ rejectUpgrade(socket, (refusal(request, current) ?? { status: 404 }).status);
120
+ });
121
+ server.on('clientError', (_error, socket) => socket.destroy());
122
+
123
+ return server;
124
+ }
package/lib/input.mjs ADDED
@@ -0,0 +1,137 @@
1
+ // Turns the input a viewer relays into Chrome DevTools Protocol commands for
2
+ // the page on screen. A viewer sends pointer positions as fractions of the
3
+ // frame it was shown (0 to 1 from the left and top), and they are scaled to
4
+ // the page's CSS pixels from the latest frame's metadata.
5
+ //
6
+ // Messages:
7
+ // { type: "mouse", action: "move"|"down"|"up", x, y, button, clickCount, modifiers }
8
+ // { type: "wheel", x, y, deltaX, deltaY, modifiers }
9
+ // { type: "key", action: "down"|"up", key, code, keyCode, modifiers }
10
+ // { type: "text", text }
11
+ //
12
+ // `modifiers` is the CDP bit field: Alt 1, Ctrl 2, Meta 4, Shift 8.
13
+
14
+ const MOUSE_TYPES = { move: 'mouseMoved', down: 'mousePressed', up: 'mouseReleased' };
15
+ const BUTTONS = new Set(['none', 'left', 'middle', 'right']);
16
+ const CTRL = 2;
17
+ const META = 4;
18
+ const MAX_KEY_CHARS = 32;
19
+ const MAX_TEXT_CHARS = 2000;
20
+ const MAX_WHEEL_DELTA = 10_000;
21
+ // The text a key types when its key value is not the character itself.
22
+ const KEY_TEXT = { Enter: '\r' };
23
+ // Windows virtual key codes, for a key the viewer sent none for.
24
+ const KEY_CODES = {
25
+ Backspace: 8, Tab: 9, Enter: 13, Shift: 16, Control: 17, Alt: 18, Pause: 19, CapsLock: 20, Escape: 27, ' ': 32,
26
+ PageUp: 33, PageDown: 34, End: 35, Home: 36, ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
27
+ Insert: 45, Delete: 46, Meta: 91,
28
+ };
29
+
30
+ function fraction(value) {
31
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
32
+ }
33
+
34
+ function modifiers(value) {
35
+ return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
36
+ }
37
+
38
+ function boundedNumber(value, limit) {
39
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
40
+ return Math.max(-limit, Math.min(limit, value));
41
+ }
42
+
43
+ /**
44
+ * Returns the CSS pixel position of a point given as fractions of a frame
45
+ * whose metadata is `metadata`, or null without a frame to scale by.
46
+ *
47
+ * @param {{ x: number, y: number }} point
48
+ * @param {{ deviceWidth: number, deviceHeight: number, offsetTop?: number } | null} metadata
49
+ * @returns {{ x: number, y: number } | null}
50
+ */
51
+ export function pagePoint({ x, y }, metadata) {
52
+ if (!fraction(x) || !fraction(y) || !metadata) return null;
53
+ const { deviceWidth, deviceHeight, offsetTop = 0 } = metadata;
54
+ if (!(deviceWidth > 0) || !(deviceHeight > 0)) return null;
55
+
56
+ return { x: Math.round(x * deviceWidth), y: Math.round(y * deviceHeight + offsetTop) };
57
+ }
58
+
59
+ /**
60
+ * Returns `[method, params]` for one relayed input message, or null when the
61
+ * message is not one the viewer may send, or a pointer message arrives
62
+ * before any frame was shown.
63
+ *
64
+ * @param {object} message
65
+ * @param {object | null} metadata the latest frame's metadata
66
+ * @returns {[string, object] | null}
67
+ */
68
+ export function inputCommand(message, metadata) {
69
+ switch (message?.type) {
70
+ case 'mouse':
71
+ return mouseCommand(message, metadata);
72
+ case 'wheel':
73
+ return wheelCommand(message, metadata);
74
+ case 'key':
75
+ return keyCommand(message);
76
+ case 'text':
77
+ return textCommand(message);
78
+ default:
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function mouseCommand(message, metadata) {
84
+ const type = MOUSE_TYPES[message.action];
85
+ const point = pagePoint(message, metadata);
86
+ if (!type || !point) return null;
87
+
88
+ const button = BUTTONS.has(message.button) ? message.button : type === 'mouseMoved' ? 'none' : 'left';
89
+ const clickCount = Number.isInteger(message.clickCount) && message.clickCount >= 0 && message.clickCount <= 3 ? message.clickCount : 1;
90
+ const params = { type, ...point, button, modifiers: modifiers(message.modifiers) };
91
+ if (type !== 'mouseMoved') params.clickCount = clickCount;
92
+ return ['Input.dispatchMouseEvent', params];
93
+ }
94
+
95
+ function wheelCommand(message, metadata) {
96
+ const point = pagePoint(message, metadata);
97
+ if (!point) return null;
98
+
99
+ return ['Input.dispatchMouseEvent', {
100
+ type: 'mouseWheel',
101
+ ...point,
102
+ deltaX: boundedNumber(message.deltaX, MAX_WHEEL_DELTA),
103
+ deltaY: boundedNumber(message.deltaY, MAX_WHEEL_DELTA),
104
+ modifiers: modifiers(message.modifiers),
105
+ }];
106
+ }
107
+
108
+ // The text a key down types: the character of a printable key, or Enter's
109
+ // carriage return. A key pressed with Ctrl or Meta is a shortcut and types
110
+ // nothing.
111
+ function keyText(key, mask) {
112
+ if (mask & (CTRL | META)) return '';
113
+ if (KEY_TEXT[key]) return KEY_TEXT[key];
114
+ return [...key].length === 1 ? key : '';
115
+ }
116
+
117
+ function keyCommand(message) {
118
+ const { action, key } = message;
119
+ if ((action !== 'down' && action !== 'up') || typeof key !== 'string' || key === '' || key.length > MAX_KEY_CHARS) return null;
120
+
121
+ const code = typeof message.code === 'string' && message.code.length <= MAX_KEY_CHARS ? message.code : '';
122
+ const mask = modifiers(message.modifiers);
123
+ let keyCode = Number.isInteger(message.keyCode) && message.keyCode > 0 && message.keyCode < 256 ? message.keyCode : KEY_CODES[key];
124
+ if (keyCode === undefined && /^[a-z0-9]$/i.test(key)) keyCode = key.toUpperCase().charCodeAt(0);
125
+
126
+ const params = { key, code, modifiers: mask, windowsVirtualKeyCode: keyCode ?? 0 };
127
+ if (action === 'up') return ['Input.dispatchKeyEvent', { type: 'keyUp', ...params }];
128
+
129
+ const text = keyText(key, mask);
130
+ return ['Input.dispatchKeyEvent', text ? { type: 'keyDown', ...params, text, unmodifiedText: text } : { type: 'rawKeyDown', ...params }];
131
+ }
132
+
133
+ function textCommand(message) {
134
+ if (typeof message.text !== 'string' || message.text === '' || message.text.length > MAX_TEXT_CHARS) return null;
135
+
136
+ return ['Input.insertText', { text: message.text }];
137
+ }