@restregular/dsh-visual-browser 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -2
- package/client.js +835 -0
- package/cordis.patch.yml +17 -0
- package/icon.svg +21 -0
- package/index.js +43 -0
- package/lib/browser.js +963 -0
- package/lib/cdp.js +192 -0
- package/lib/format.js +87 -0
- package/lib/page-scripts.js +528 -0
- package/lib/routes.js +201 -0
- package/lib/tools.js +168 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -4
package/lib/cdp.js
ADDED
|
@@ -0,0 +1,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
|
+
#closed = false;
|
|
28
|
+
|
|
29
|
+
constructor(socket) {
|
|
30
|
+
this.#socket = socket;
|
|
31
|
+
socket.addEventListener('message', (event) => this.#onMessage(event.data));
|
|
32
|
+
socket.addEventListener('close', () => this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed')));
|
|
33
|
+
socket.addEventListener('error', () => {
|
|
34
|
+
/* The close event carries the outcome; keep this listener quiet. */
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Connect to a browser-level or page-level WebSocket endpoint. */
|
|
39
|
+
static async open(url, { timeoutMs = OPEN_TIMEOUT_MS } = {}) {
|
|
40
|
+
const Socket = globalThis.WebSocket;
|
|
41
|
+
if (typeof Socket !== 'function') {
|
|
42
|
+
throw new CdpError('CDP_UNAVAILABLE', 'this Node.js runtime has no built-in WebSocket (Node 22 or newer is required)');
|
|
43
|
+
}
|
|
44
|
+
const socket = new Socket(url);
|
|
45
|
+
await new Promise((resolve, reject) => {
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
cleanup();
|
|
48
|
+
try {
|
|
49
|
+
socket.close();
|
|
50
|
+
} catch {
|
|
51
|
+
/* already unusable */
|
|
52
|
+
}
|
|
53
|
+
reject(new CdpError('CDP_TIMEOUT', `timed out connecting to ${url}`));
|
|
54
|
+
}, timeoutMs);
|
|
55
|
+
const cleanup = () => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
socket.removeEventListener('open', onOpen);
|
|
58
|
+
socket.removeEventListener('error', onError);
|
|
59
|
+
};
|
|
60
|
+
const onOpen = () => {
|
|
61
|
+
cleanup();
|
|
62
|
+
resolve();
|
|
63
|
+
};
|
|
64
|
+
const onError = () => {
|
|
65
|
+
cleanup();
|
|
66
|
+
reject(new CdpError('CDP_CONNECT_FAILED', `could not connect to ${url}`));
|
|
67
|
+
};
|
|
68
|
+
socket.addEventListener('open', onOpen);
|
|
69
|
+
socket.addEventListener('error', onError);
|
|
70
|
+
});
|
|
71
|
+
return new CdpConnection(socket);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get closed() {
|
|
75
|
+
return this.#closed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Send one protocol command; `sessionId` targets a flattened session. */
|
|
79
|
+
send(method, params = {}, sessionId) {
|
|
80
|
+
if (this.#closed) return Promise.reject(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
|
|
81
|
+
const id = this.#nextId++;
|
|
82
|
+
const payload = { id, method, params };
|
|
83
|
+
if (sessionId !== undefined) payload.sessionId = sessionId;
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
this.#pending.delete(id);
|
|
87
|
+
reject(new CdpError('CDP_COMMAND_TIMEOUT', `${method} did not answer within ${COMMAND_TIMEOUT_MS}ms`));
|
|
88
|
+
}, COMMAND_TIMEOUT_MS);
|
|
89
|
+
this.#pending.set(id, { resolve, reject, method, timer });
|
|
90
|
+
try {
|
|
91
|
+
this.#socket.send(JSON.stringify(payload));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
this.#pending.delete(id);
|
|
95
|
+
reject(new CdpError('CDP_SEND_FAILED', messageOf(error)));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Subscribe to one protocol event; the returned function unsubscribes. */
|
|
101
|
+
on(method, listener) {
|
|
102
|
+
const bucket = this.#listeners.get(method) ?? new Set();
|
|
103
|
+
bucket.add(listener);
|
|
104
|
+
this.#listeners.set(method, bucket);
|
|
105
|
+
return () => {
|
|
106
|
+
bucket.delete(listener);
|
|
107
|
+
if (bucket.size === 0) this.#listeners.delete(method);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Resolve on the next `method` event, or reject on timeout. */
|
|
112
|
+
once(method, timeoutMs) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
off();
|
|
116
|
+
reject(new CdpError('CDP_EVENT_TIMEOUT', `waited ${timeoutMs}ms for ${method}`));
|
|
117
|
+
}, timeoutMs);
|
|
118
|
+
const off = this.on(method, (params, sessionId) => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
off();
|
|
121
|
+
resolve({ params, sessionId });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
close() {
|
|
127
|
+
if (this.#closed) return;
|
|
128
|
+
this.#failAll(new CdpError('CDP_CLOSED', 'the DevTools connection closed'));
|
|
129
|
+
try {
|
|
130
|
+
this.#socket.close();
|
|
131
|
+
} catch {
|
|
132
|
+
/* already closed */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#onMessage(raw) {
|
|
137
|
+
let message;
|
|
138
|
+
try {
|
|
139
|
+
message = JSON.parse(typeof raw === 'string' ? raw : String(raw));
|
|
140
|
+
} catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (message && typeof message.id === 'number') {
|
|
144
|
+
const pending = this.#pending.get(message.id);
|
|
145
|
+
if (pending === undefined) return;
|
|
146
|
+
this.#pending.delete(message.id);
|
|
147
|
+
clearTimeout(pending.timer);
|
|
148
|
+
if (message.error) {
|
|
149
|
+
pending.reject(
|
|
150
|
+
new CdpError(
|
|
151
|
+
`CDP_${String(message.error.code ?? 'ERROR')}`,
|
|
152
|
+
`${pending.method}: ${message.error.message ?? 'protocol error'}`,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
} else {
|
|
156
|
+
pending.resolve(message.result);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!message || typeof message.method !== 'string') return;
|
|
161
|
+
const bucket = this.#listeners.get(message.method);
|
|
162
|
+
if (bucket === undefined) return;
|
|
163
|
+
for (const listener of [...bucket]) {
|
|
164
|
+
try {
|
|
165
|
+
listener(message.params, message.sessionId);
|
|
166
|
+
} catch {
|
|
167
|
+
/* a failing listener must not break the socket */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#failAll(error) {
|
|
173
|
+
if (this.#closed) return;
|
|
174
|
+
this.#closed = true;
|
|
175
|
+
for (const [id, pending] of this.#pending) {
|
|
176
|
+
clearTimeout(pending.timer);
|
|
177
|
+
this.#pending.delete(id);
|
|
178
|
+
pending.reject(error);
|
|
179
|
+
}
|
|
180
|
+
this.#listeners.clear();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function messageOf(error) {
|
|
185
|
+
if (error instanceof Error) return error.message;
|
|
186
|
+
if (typeof error === 'string') return error;
|
|
187
|
+
try {
|
|
188
|
+
return JSON.stringify(error);
|
|
189
|
+
} catch {
|
|
190
|
+
return String(error);
|
|
191
|
+
}
|
|
192
|
+
}
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text projection of a page snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only: the same snapshot object is rendered for the model (this
|
|
5
|
+
* module) and for the panel (raw JSON over the route), so a formatting change
|
|
6
|
+
* never needs a browser.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MAX_ELEMENTS = 100;
|
|
10
|
+
|
|
11
|
+
/** Render one snapshot as the compact view a model reads. */
|
|
12
|
+
export function formatSnapshot(snapshot, options = {}) {
|
|
13
|
+
if (!snapshot || typeof snapshot !== 'object') return 'No page snapshot available.';
|
|
14
|
+
const maxElements = Number.isFinite(options.maxElements) ? options.maxElements : DEFAULT_MAX_ELEMENTS;
|
|
15
|
+
const lines = [];
|
|
16
|
+
const url = String(snapshot.url || 'about:blank');
|
|
17
|
+
const title = String(snapshot.title || '').trim();
|
|
18
|
+
lines.push(`URL: ${url}`);
|
|
19
|
+
if (title) lines.push(`TITLE: ${title}`);
|
|
20
|
+
|
|
21
|
+
const viewport = snapshot.viewport || { width: 0, height: 0 };
|
|
22
|
+
const scrollY = Number(snapshot.scrollY) || 0;
|
|
23
|
+
const docHeight = Number(snapshot.docHeight) || 0;
|
|
24
|
+
const total = Number(snapshot.total) || (Array.isArray(snapshot.elements) ? snapshot.elements.length : 0);
|
|
25
|
+
const elements = Array.isArray(snapshot.elements) ? snapshot.elements.slice(0, maxElements) : [];
|
|
26
|
+
const scroll = docHeight > viewport.height ? ` scroll ${scrollY}/${docHeight}` : '';
|
|
27
|
+
lines.push(`VIEW: ${viewport.width}x${viewport.height}${scroll} · ${elements.length}/${total} interactive`);
|
|
28
|
+
|
|
29
|
+
if (elements.length === 0) {
|
|
30
|
+
lines.push('ELEMENTS: none visible in the viewport');
|
|
31
|
+
} else {
|
|
32
|
+
for (const element of elements) lines.push(formatElement(element));
|
|
33
|
+
if (total > elements.length) {
|
|
34
|
+
lines.push(`… ${total - elements.length} more interactive elements below the fold; scroll to reach them`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const headings = Array.isArray(snapshot.headings) ? snapshot.headings.filter(Boolean) : [];
|
|
39
|
+
if (headings.length > 0) lines.push(`HEADINGS: ${headings.join(' | ')}`);
|
|
40
|
+
|
|
41
|
+
const text = String(snapshot.text || '').trim();
|
|
42
|
+
if (text) lines.push(`TEXT: ${text}`);
|
|
43
|
+
|
|
44
|
+
const active = snapshot.active;
|
|
45
|
+
if (active && (active.ref || active.name)) {
|
|
46
|
+
const target = active.ref ? `[${active.ref}] ` : '';
|
|
47
|
+
lines.push(`ACTIVE: ${target}${active.role}${active.name ? ` "${active.name}"` : ''}`);
|
|
48
|
+
}
|
|
49
|
+
if (Number(snapshot.frames) > 0) {
|
|
50
|
+
lines.push(`IFRAMES: ${snapshot.frames} (cross-frame content is not inspected; use a direct URL when possible)`);
|
|
51
|
+
}
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One numbered element line. */
|
|
56
|
+
export function formatElement(element) {
|
|
57
|
+
const ref = element.ref;
|
|
58
|
+
const role = element.role || 'element';
|
|
59
|
+
const name = String(element.name || '').trim();
|
|
60
|
+
let line = `[${ref}] ${role}`;
|
|
61
|
+
if (name) line += ` "${name}"`;
|
|
62
|
+
if (element.value !== undefined && element.value !== '') line += ` value=${element.value}`;
|
|
63
|
+
if (Array.isArray(element.state) && element.state.length > 0) line += ` (${element.state.join(', ')})`;
|
|
64
|
+
if (element.href) line += ` → ${element.href}`;
|
|
65
|
+
return line;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One timeline row label; also used by the panel when it renders raw state. */
|
|
69
|
+
export function describeAction(action) {
|
|
70
|
+
if (action === null || action === undefined) return '';
|
|
71
|
+
if (typeof action === 'string') return action;
|
|
72
|
+
if (typeof action.detail === 'string' && action.detail.trim() !== '') return action.detail.trim();
|
|
73
|
+
const kind = String(action.kind || 'action');
|
|
74
|
+
const target = action.target ? ` ${action.target}` : '';
|
|
75
|
+
return `${kind}${target}`.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Percentage of the document scrolled, for compact state payloads. */
|
|
79
|
+
export function scrollProgress(snapshot) {
|
|
80
|
+
if (!snapshot) return 0;
|
|
81
|
+
const docHeight = Number(snapshot.docHeight) || 0;
|
|
82
|
+
const viewport = Number(snapshot.viewport && snapshot.viewport.height) || 0;
|
|
83
|
+
if (docHeight <= viewport || viewport <= 0) return 0;
|
|
84
|
+
const max = docHeight - viewport;
|
|
85
|
+
const value = Math.min(1, Math.max(0, (Number(snapshot.scrollY) || 0) / max));
|
|
86
|
+
return Math.round(value * 100);
|
|
87
|
+
}
|