e2e-pilot 0.0.69
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/bin.js +3 -0
- package/dist/aria-snapshot.d.ts +95 -0
- package/dist/aria-snapshot.d.ts.map +1 -0
- package/dist/aria-snapshot.js +490 -0
- package/dist/aria-snapshot.js.map +1 -0
- package/dist/bippy.js +971 -0
- package/dist/cdp-relay.d.ts +16 -0
- package/dist/cdp-relay.d.ts.map +1 -0
- package/dist/cdp-relay.js +715 -0
- package/dist/cdp-relay.js.map +1 -0
- package/dist/cdp-session.d.ts +42 -0
- package/dist/cdp-session.d.ts.map +1 -0
- package/dist/cdp-session.js +154 -0
- package/dist/cdp-session.js.map +1 -0
- package/dist/cdp-types.d.ts +63 -0
- package/dist/cdp-types.d.ts.map +1 -0
- package/dist/cdp-types.js +91 -0
- package/dist/cdp-types.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +213 -0
- package/dist/cli.js.map +1 -0
- package/dist/create-logger.d.ts +9 -0
- package/dist/create-logger.d.ts.map +1 -0
- package/dist/create-logger.js +25 -0
- package/dist/create-logger.js.map +1 -0
- package/dist/debugger-api.md +458 -0
- package/dist/debugger-examples-types.d.ts +24 -0
- package/dist/debugger-examples-types.d.ts.map +1 -0
- package/dist/debugger-examples-types.js +2 -0
- package/dist/debugger-examples-types.js.map +1 -0
- package/dist/debugger-examples.d.ts +6 -0
- package/dist/debugger-examples.d.ts.map +1 -0
- package/dist/debugger-examples.js +53 -0
- package/dist/debugger-examples.js.map +1 -0
- package/dist/debugger.d.ts +381 -0
- package/dist/debugger.d.ts.map +1 -0
- package/dist/debugger.js +633 -0
- package/dist/debugger.js.map +1 -0
- package/dist/editor-api.md +364 -0
- package/dist/editor-examples.d.ts +11 -0
- package/dist/editor-examples.d.ts.map +1 -0
- package/dist/editor-examples.js +124 -0
- package/dist/editor-examples.js.map +1 -0
- package/dist/editor.d.ts +203 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +336 -0
- package/dist/editor.js.map +1 -0
- package/dist/execute.d.ts +50 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.js +576 -0
- package/dist/execute.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-client.d.ts +20 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +56 -0
- package/dist/mcp-client.js.map +1 -0
- package/dist/mcp.d.ts +5 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +720 -0
- package/dist/mcp.js.map +1 -0
- package/dist/mcp.test.d.ts +10 -0
- package/dist/mcp.test.d.ts.map +1 -0
- package/dist/mcp.test.js +2999 -0
- package/dist/mcp.test.js.map +1 -0
- package/dist/network-capture.d.ts +23 -0
- package/dist/network-capture.d.ts.map +1 -0
- package/dist/network-capture.js +98 -0
- package/dist/network-capture.js.map +1 -0
- package/dist/protocol.d.ts +54 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +2 -0
- package/dist/protocol.js.map +1 -0
- package/dist/react-source.d.ts +13 -0
- package/dist/react-source.d.ts.map +1 -0
- package/dist/react-source.js +68 -0
- package/dist/react-source.js.map +1 -0
- package/dist/scoped-fs.d.ts +94 -0
- package/dist/scoped-fs.d.ts.map +1 -0
- package/dist/scoped-fs.js +356 -0
- package/dist/scoped-fs.js.map +1 -0
- package/dist/selector-generator.js +8126 -0
- package/dist/start-relay-server.d.ts +6 -0
- package/dist/start-relay-server.d.ts.map +1 -0
- package/dist/start-relay-server.js +33 -0
- package/dist/start-relay-server.js.map +1 -0
- package/dist/styles-api.md +117 -0
- package/dist/styles-examples.d.ts +8 -0
- package/dist/styles-examples.d.ts.map +1 -0
- package/dist/styles-examples.js +64 -0
- package/dist/styles-examples.js.map +1 -0
- package/dist/styles.d.ts +27 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +234 -0
- package/dist/styles.js.map +1 -0
- package/dist/trace-utils.d.ts +14 -0
- package/dist/trace-utils.d.ts.map +1 -0
- package/dist/trace-utils.js +21 -0
- package/dist/trace-utils.js.map +1 -0
- package/dist/utils.d.ts +20 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +75 -0
- package/dist/utils.js.map +1 -0
- package/dist/wait-for-page-load.d.ts +16 -0
- package/dist/wait-for-page-load.d.ts.map +1 -0
- package/dist/wait-for-page-load.js +127 -0
- package/dist/wait-for-page-load.js.map +1 -0
- package/package.json +67 -0
- package/src/aria-snapshot.ts +610 -0
- package/src/assets/aria-labels-github-snapshot.txt +605 -0
- package/src/assets/aria-labels-github.png +0 -0
- package/src/assets/aria-labels-google-snapshot.txt +49 -0
- package/src/assets/aria-labels-google.png +0 -0
- package/src/assets/aria-labels-hacker-news-snapshot.txt +1023 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/cdp-relay.ts +925 -0
- package/src/cdp-session.ts +203 -0
- package/src/cdp-timing.md +128 -0
- package/src/cdp-types.ts +155 -0
- package/src/cli.ts +250 -0
- package/src/create-logger.ts +36 -0
- package/src/debugger-examples-types.ts +13 -0
- package/src/debugger-examples.ts +66 -0
- package/src/debugger.md +453 -0
- package/src/debugger.ts +713 -0
- package/src/editor-examples.ts +148 -0
- package/src/editor.ts +390 -0
- package/src/execute.ts +763 -0
- package/src/index.ts +10 -0
- package/src/mcp-client.ts +78 -0
- package/src/mcp.test.ts +3596 -0
- package/src/mcp.ts +876 -0
- package/src/network-capture.ts +140 -0
- package/src/prompt.bak.md +323 -0
- package/src/prompt.md +7 -0
- package/src/protocol.ts +63 -0
- package/src/react-source.ts +94 -0
- package/src/resource.md +436 -0
- package/src/scoped-fs.ts +411 -0
- package/src/snapshots/hacker-news-focused-accessibility.md +202 -0
- package/src/snapshots/hacker-news-initial-accessibility.md +11 -0
- package/src/snapshots/hacker-news-tabbed-accessibility.md +202 -0
- package/src/snapshots/shadcn-ui-accessibility.md +11 -0
- package/src/start-relay-server.ts +43 -0
- package/src/styles-examples.ts +77 -0
- package/src/styles.ts +345 -0
- package/src/trace-utils.ts +43 -0
- package/src/utils.ts +91 -0
- package/src/wait-for-page-load.ts +174 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { serve } from '@hono/node-server';
|
|
3
|
+
import { getConnInfo } from '@hono/node-server/conninfo';
|
|
4
|
+
import { createNodeWebSocket } from '@hono/node-ws';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import { VERSION } from './utils.js';
|
|
8
|
+
export async function startE2EPilotCDPRelayServer({ port = 19988, host = '127.0.0.1', token, logger, } = {}) {
|
|
9
|
+
const emitter = new EventEmitter();
|
|
10
|
+
const connectedTargets = new Map();
|
|
11
|
+
const playwrightClients = new Map();
|
|
12
|
+
let extensionWs = null;
|
|
13
|
+
const extensionPendingRequests = new Map();
|
|
14
|
+
let extensionMessageId = 0;
|
|
15
|
+
let extensionPingInterval = null;
|
|
16
|
+
function startExtensionPing() {
|
|
17
|
+
if (extensionPingInterval) {
|
|
18
|
+
clearInterval(extensionPingInterval);
|
|
19
|
+
}
|
|
20
|
+
extensionPingInterval = setInterval(() => {
|
|
21
|
+
extensionWs?.send(JSON.stringify({ method: 'ping' }));
|
|
22
|
+
}, 5000);
|
|
23
|
+
}
|
|
24
|
+
function stopExtensionPing() {
|
|
25
|
+
if (extensionPingInterval) {
|
|
26
|
+
clearInterval(extensionPingInterval);
|
|
27
|
+
extensionPingInterval = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function logCdpMessage({ direction, clientId, method, sessionId, params, id, source, }) {
|
|
31
|
+
const noisyEvents = [
|
|
32
|
+
'Network.requestWillBeSentExtraInfo',
|
|
33
|
+
'Network.responseReceived',
|
|
34
|
+
'Network.responseReceivedExtraInfo',
|
|
35
|
+
'Network.dataReceived',
|
|
36
|
+
'Network.requestWillBeSent',
|
|
37
|
+
'Network.loadingFinished',
|
|
38
|
+
];
|
|
39
|
+
if (noisyEvents.includes(method)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const details = [];
|
|
43
|
+
if (id !== undefined) {
|
|
44
|
+
details.push(`id=${id}`);
|
|
45
|
+
}
|
|
46
|
+
if (sessionId) {
|
|
47
|
+
details.push(`sessionId=${sessionId}`);
|
|
48
|
+
}
|
|
49
|
+
if (params) {
|
|
50
|
+
if (params.targetId) {
|
|
51
|
+
details.push(`targetId=${params.targetId}`);
|
|
52
|
+
}
|
|
53
|
+
if (params.targetInfo?.targetId) {
|
|
54
|
+
details.push(`targetId=${params.targetInfo.targetId}`);
|
|
55
|
+
}
|
|
56
|
+
if (params.sessionId && params.sessionId !== sessionId) {
|
|
57
|
+
details.push(`sessionId=${params.sessionId}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const detailsStr = details.length > 0 ? ` ${chalk.gray(details.join(', '))}` : '';
|
|
61
|
+
if (direction === 'from-playwright') {
|
|
62
|
+
const clientLabel = clientId ? chalk.blue(`[${clientId}]`) : '';
|
|
63
|
+
logger?.log(chalk.cyan('← Playwright'), clientLabel + ':', method + detailsStr);
|
|
64
|
+
}
|
|
65
|
+
else if (direction === 'from-extension') {
|
|
66
|
+
logger?.log(chalk.yellow('← Extension:'), method + detailsStr);
|
|
67
|
+
}
|
|
68
|
+
else if (direction === 'to-playwright') {
|
|
69
|
+
const color = source === 'server' ? chalk.magenta : chalk.green;
|
|
70
|
+
const sourceLabel = source === 'server' ? chalk.gray(' (server-generated)') : '';
|
|
71
|
+
const clientLabel = clientId ? chalk.blue(`[${clientId}]`) : chalk.blue('[ALL]');
|
|
72
|
+
logger?.log(color('→ Playwright'), clientLabel + ':', method + detailsStr + sourceLabel);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function sendToPlaywright({ message, clientId, source = 'extension', }) {
|
|
76
|
+
const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message;
|
|
77
|
+
if ('method' in message) {
|
|
78
|
+
logCdpMessage({
|
|
79
|
+
direction: 'to-playwright',
|
|
80
|
+
clientId,
|
|
81
|
+
method: message.method,
|
|
82
|
+
sessionId: 'sessionId' in message ? message.sessionId : undefined,
|
|
83
|
+
params: 'params' in message ? message.params : undefined,
|
|
84
|
+
source,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const messageStr = JSON.stringify(messageToSend);
|
|
88
|
+
if (clientId) {
|
|
89
|
+
const client = playwrightClients.get(clientId);
|
|
90
|
+
if (client) {
|
|
91
|
+
client.ws.send(messageStr);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
for (const client of playwrightClients.values()) {
|
|
96
|
+
client.ws.send(messageStr);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function sendToExtension({ method, params, timeout = 30000, }) {
|
|
101
|
+
if (!extensionWs) {
|
|
102
|
+
throw new Error('Extension not connected');
|
|
103
|
+
}
|
|
104
|
+
const id = ++extensionMessageId;
|
|
105
|
+
const message = { id, method, params };
|
|
106
|
+
extensionWs.send(JSON.stringify(message));
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const timeoutId = setTimeout(() => {
|
|
109
|
+
extensionPendingRequests.delete(id);
|
|
110
|
+
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`));
|
|
111
|
+
}, timeout);
|
|
112
|
+
extensionPendingRequests.set(id, {
|
|
113
|
+
resolve: (result) => {
|
|
114
|
+
clearTimeout(timeoutId);
|
|
115
|
+
resolve(result);
|
|
116
|
+
},
|
|
117
|
+
reject: (error) => {
|
|
118
|
+
clearTimeout(timeoutId);
|
|
119
|
+
reject(error);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Auto-create initial tab when E2E_PILOT_AUTO_ENABLE is set and no targets exist.
|
|
125
|
+
// This allows Playwright to connect and immediately have a page to work with.
|
|
126
|
+
async function maybeAutoCreateInitialTab() {
|
|
127
|
+
if (!process.env.E2E_PILOT_AUTO_ENABLE) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!extensionWs) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (connectedTargets.size > 0) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
logger?.log(chalk.blue('Auto-creating initial tab for Playwright client'));
|
|
138
|
+
const result = (await sendToExtension({ method: 'createInitialTab', timeout: 10000 }));
|
|
139
|
+
if (result.success && result.sessionId && result.targetInfo) {
|
|
140
|
+
connectedTargets.set(result.sessionId, {
|
|
141
|
+
sessionId: result.sessionId,
|
|
142
|
+
targetId: result.targetInfo.targetId,
|
|
143
|
+
targetInfo: result.targetInfo,
|
|
144
|
+
});
|
|
145
|
+
logger?.log(chalk.blue(`Auto-created tab, now have ${connectedTargets.size} targets, url: ${result.targetInfo.url}`));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
logger?.error('Failed to auto-create initial tab:', e);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function routeCdpCommand({ method, params, sessionId }) {
|
|
153
|
+
switch (method) {
|
|
154
|
+
case 'Browser.getVersion': {
|
|
155
|
+
return {
|
|
156
|
+
protocolVersion: '1.3',
|
|
157
|
+
product: 'Chrome/Extension-Bridge',
|
|
158
|
+
revision: '1.0.0',
|
|
159
|
+
userAgent: 'CDP-Bridge-Server/1.0.0',
|
|
160
|
+
jsVersion: 'V8',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
case 'Browser.setDownloadBehavior': {
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
// Target.setAutoAttach is a CDP command Playwright sends on first connection.
|
|
167
|
+
// We use it as the hook to auto-create an initial tab. If Playwright changes
|
|
168
|
+
// its initialization sequence in the future, this could be moved to a different command.
|
|
169
|
+
case 'Target.setAutoAttach': {
|
|
170
|
+
if (sessionId) {
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
await maybeAutoCreateInitialTab();
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
case 'Target.setDiscoverTargets': {
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
case 'Target.attachToTarget': {
|
|
180
|
+
const targetId = params?.targetId;
|
|
181
|
+
if (!targetId) {
|
|
182
|
+
throw new Error('targetId is required for Target.attachToTarget');
|
|
183
|
+
}
|
|
184
|
+
for (const target of connectedTargets.values()) {
|
|
185
|
+
if (target.targetId === targetId) {
|
|
186
|
+
return { sessionId: target.sessionId };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`Target ${targetId} not found in connected targets`);
|
|
190
|
+
}
|
|
191
|
+
case 'Target.getTargetInfo': {
|
|
192
|
+
const targetId = params?.targetId;
|
|
193
|
+
if (targetId) {
|
|
194
|
+
for (const target of connectedTargets.values()) {
|
|
195
|
+
if (target.targetId === targetId) {
|
|
196
|
+
return { targetInfo: target.targetInfo };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (sessionId) {
|
|
201
|
+
const target = connectedTargets.get(sessionId);
|
|
202
|
+
if (target) {
|
|
203
|
+
return { targetInfo: target.targetInfo };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const firstTarget = Array.from(connectedTargets.values())[0];
|
|
207
|
+
return { targetInfo: firstTarget?.targetInfo };
|
|
208
|
+
}
|
|
209
|
+
case 'Target.getTargets': {
|
|
210
|
+
return {
|
|
211
|
+
targetInfos: Array.from(connectedTargets.values()).map((t) => ({
|
|
212
|
+
...t.targetInfo,
|
|
213
|
+
attached: true,
|
|
214
|
+
})),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
case 'Target.createTarget': {
|
|
218
|
+
return await sendToExtension({
|
|
219
|
+
method: 'forwardCDPCommand',
|
|
220
|
+
params: { method, params },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
case 'Target.closeTarget': {
|
|
224
|
+
return await sendToExtension({
|
|
225
|
+
method: 'forwardCDPCommand',
|
|
226
|
+
params: { method, params },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
case 'Runtime.enable': {
|
|
230
|
+
if (!sessionId) {
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
const contextCreatedPromise = new Promise((resolve) => {
|
|
234
|
+
const handler = ({ event }) => {
|
|
235
|
+
if (event.method === 'Runtime.executionContextCreated' && event.sessionId === sessionId) {
|
|
236
|
+
const params = event.params;
|
|
237
|
+
if (params?.context?.auxData?.isDefault === true) {
|
|
238
|
+
clearTimeout(timeout);
|
|
239
|
+
emitter.off('cdp:event', handler);
|
|
240
|
+
resolve();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const timeout = setTimeout(() => {
|
|
245
|
+
emitter.off('cdp:event', handler);
|
|
246
|
+
logger?.log(chalk.yellow(`IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`));
|
|
247
|
+
resolve();
|
|
248
|
+
}, 3000);
|
|
249
|
+
emitter.on('cdp:event', handler);
|
|
250
|
+
});
|
|
251
|
+
const result = await sendToExtension({
|
|
252
|
+
method: 'forwardCDPCommand',
|
|
253
|
+
params: { sessionId, method, params },
|
|
254
|
+
});
|
|
255
|
+
await contextCreatedPromise;
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return await sendToExtension({
|
|
260
|
+
method: 'forwardCDPCommand',
|
|
261
|
+
params: { sessionId, method, params },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const app = new Hono();
|
|
265
|
+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
|
266
|
+
app.get('/', (c) => {
|
|
267
|
+
return c.text('OK');
|
|
268
|
+
});
|
|
269
|
+
app.get('/version', (c) => {
|
|
270
|
+
return c.json({ version: VERSION });
|
|
271
|
+
});
|
|
272
|
+
app.get('/extension/status', (c) => {
|
|
273
|
+
return c.json({ connected: extensionWs !== null });
|
|
274
|
+
});
|
|
275
|
+
app.post('/mcp-log', async (c) => {
|
|
276
|
+
try {
|
|
277
|
+
const { level, args } = await c.req.json();
|
|
278
|
+
const logFn = logger?.[level] || logger?.log;
|
|
279
|
+
const prefix = chalk.red(`[MCP] [${level.toUpperCase()}]`);
|
|
280
|
+
logFn?.(prefix, ...args);
|
|
281
|
+
return c.json({ ok: true });
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return c.json({ ok: false }, 400);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
// Validate Origin header for WebSocket connections to prevent cross-origin attacks.
|
|
288
|
+
// Browsers always send Origin header for WebSocket connections, but Node.js clients don't.
|
|
289
|
+
// We allow any chrome-extension:// origin since the localhost IP check already ensures
|
|
290
|
+
// only local connections are accepted - the main security concern.
|
|
291
|
+
// This avoids hardcoding extension IDs which vary per machine for unpacked extensions.
|
|
292
|
+
app.get('/cdp/:clientId?', (c, next) => {
|
|
293
|
+
const clientId = c.req.param('clientId') || 'default';
|
|
294
|
+
const origin = c.req.header('origin');
|
|
295
|
+
logger?.log(chalk.blue(`CDP connection request: clientId=${clientId}, origin=${origin || 'none'}`));
|
|
296
|
+
// Validate Origin header if present (Node.js clients don't send it)
|
|
297
|
+
// Only allow chrome-extension:// origins - reject http/https origins from websites
|
|
298
|
+
if (origin && !origin.startsWith('chrome-extension://')) {
|
|
299
|
+
logger?.log(chalk.red(`Rejecting /cdp WebSocket from origin: ${origin}`));
|
|
300
|
+
return c.text('Forbidden', 403);
|
|
301
|
+
}
|
|
302
|
+
if (token) {
|
|
303
|
+
const url = new URL(c.req.url, 'http://localhost');
|
|
304
|
+
const providedToken = url.searchParams.get('token');
|
|
305
|
+
if (providedToken !== token) {
|
|
306
|
+
logger?.log(chalk.red(`Rejecting /cdp WebSocket: invalid token`));
|
|
307
|
+
return c.text('Unauthorized', 401);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return next();
|
|
311
|
+
}, upgradeWebSocket((c) => {
|
|
312
|
+
const clientId = c.req.param('clientId') || 'default';
|
|
313
|
+
return {
|
|
314
|
+
async onOpen(_event, ws) {
|
|
315
|
+
if (playwrightClients.has(clientId)) {
|
|
316
|
+
logger?.log(chalk.red(`Rejecting duplicate client ID: ${clientId}`));
|
|
317
|
+
ws.close(1000, 'Client ID already connected');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
// Add client first so it can receive Target.attachedToTarget events
|
|
321
|
+
playwrightClients.set(clientId, { id: clientId, ws });
|
|
322
|
+
logger?.log(chalk.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionWs}) (${connectedTargets.size} pages)`));
|
|
323
|
+
},
|
|
324
|
+
async onMessage(event, ws) {
|
|
325
|
+
let message;
|
|
326
|
+
try {
|
|
327
|
+
message = JSON.parse(event.data.toString());
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const { id, sessionId, method, params } = message;
|
|
333
|
+
logCdpMessage({
|
|
334
|
+
direction: 'from-playwright',
|
|
335
|
+
clientId,
|
|
336
|
+
method,
|
|
337
|
+
sessionId,
|
|
338
|
+
id,
|
|
339
|
+
});
|
|
340
|
+
emitter.emit('cdp:command', { clientId, command: message });
|
|
341
|
+
if (!extensionWs) {
|
|
342
|
+
sendToPlaywright({
|
|
343
|
+
message: {
|
|
344
|
+
id,
|
|
345
|
+
sessionId,
|
|
346
|
+
error: { message: 'Extension not connected' },
|
|
347
|
+
},
|
|
348
|
+
clientId,
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const result = await routeCdpCommand({ method, params, sessionId });
|
|
354
|
+
if (method === 'Target.setAutoAttach' && !sessionId) {
|
|
355
|
+
for (const target of connectedTargets.values()) {
|
|
356
|
+
const attachedPayload = {
|
|
357
|
+
method: 'Target.attachedToTarget',
|
|
358
|
+
params: {
|
|
359
|
+
sessionId: target.sessionId,
|
|
360
|
+
targetInfo: {
|
|
361
|
+
...target.targetInfo,
|
|
362
|
+
attached: true,
|
|
363
|
+
},
|
|
364
|
+
waitingForDebugger: false,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
if (!target.targetInfo.url) {
|
|
368
|
+
logger?.error(chalk.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'), JSON.stringify(attachedPayload));
|
|
369
|
+
}
|
|
370
|
+
logger?.log(chalk.magenta('[Server] Target.attachedToTarget full payload:'), JSON.stringify(attachedPayload));
|
|
371
|
+
sendToPlaywright({
|
|
372
|
+
message: attachedPayload,
|
|
373
|
+
clientId,
|
|
374
|
+
source: 'server',
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (method === 'Target.setDiscoverTargets' && params?.discover) {
|
|
379
|
+
for (const target of connectedTargets.values()) {
|
|
380
|
+
const targetCreatedPayload = {
|
|
381
|
+
method: 'Target.targetCreated',
|
|
382
|
+
params: {
|
|
383
|
+
targetInfo: {
|
|
384
|
+
...target.targetInfo,
|
|
385
|
+
attached: true,
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
if (!target.targetInfo.url) {
|
|
390
|
+
logger?.error(chalk.red('[Server] WARNING: Target.targetCreated sent with empty URL!'), JSON.stringify(targetCreatedPayload));
|
|
391
|
+
}
|
|
392
|
+
logger?.log(chalk.magenta('[Server] Target.targetCreated full payload:'), JSON.stringify(targetCreatedPayload));
|
|
393
|
+
sendToPlaywright({
|
|
394
|
+
message: targetCreatedPayload,
|
|
395
|
+
clientId,
|
|
396
|
+
source: 'server',
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (method === 'Target.attachToTarget' && result?.sessionId) {
|
|
401
|
+
const targetId = params?.targetId;
|
|
402
|
+
const target = Array.from(connectedTargets.values()).find((t) => t.targetId === targetId);
|
|
403
|
+
if (target) {
|
|
404
|
+
const attachedPayload = {
|
|
405
|
+
method: 'Target.attachedToTarget',
|
|
406
|
+
params: {
|
|
407
|
+
sessionId: result.sessionId,
|
|
408
|
+
targetInfo: {
|
|
409
|
+
...target.targetInfo,
|
|
410
|
+
attached: true,
|
|
411
|
+
},
|
|
412
|
+
waitingForDebugger: false,
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
if (!target.targetInfo.url) {
|
|
416
|
+
logger?.error(chalk.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload));
|
|
417
|
+
}
|
|
418
|
+
logger?.log(chalk.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), JSON.stringify(attachedPayload));
|
|
419
|
+
sendToPlaywright({
|
|
420
|
+
message: attachedPayload,
|
|
421
|
+
clientId,
|
|
422
|
+
source: 'server',
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const response = { id, sessionId, result };
|
|
427
|
+
sendToPlaywright({ message: response, clientId });
|
|
428
|
+
emitter.emit('cdp:response', { clientId, response, command: message });
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
logger?.error('Error handling CDP command:', method, params, e);
|
|
432
|
+
const errorResponse = {
|
|
433
|
+
id,
|
|
434
|
+
sessionId,
|
|
435
|
+
error: { message: e.message },
|
|
436
|
+
};
|
|
437
|
+
sendToPlaywright({ message: errorResponse, clientId });
|
|
438
|
+
emitter.emit('cdp:response', { clientId, response: errorResponse, command: message });
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
onClose() {
|
|
442
|
+
playwrightClients.delete(clientId);
|
|
443
|
+
logger?.log(chalk.yellow(`Playwright client disconnected: ${clientId} (${playwrightClients.size} remaining)`));
|
|
444
|
+
},
|
|
445
|
+
onError(event) {
|
|
446
|
+
logger?.error(`Playwright WebSocket error [${clientId}]:`, event);
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}));
|
|
450
|
+
app.get('/extension', (c, next) => {
|
|
451
|
+
// 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
|
|
452
|
+
// This prevents attackers on the network from hijacking the browser session
|
|
453
|
+
// even if the server is exposed via 0.0.0.0.
|
|
454
|
+
const info = getConnInfo(c);
|
|
455
|
+
const remoteAddress = info.remote.address;
|
|
456
|
+
const isLocalhost = remoteAddress === '127.0.0.1' || remoteAddress === '::1';
|
|
457
|
+
if (!isLocalhost) {
|
|
458
|
+
logger?.log(chalk.red(`Rejecting /extension WebSocket from remote IP: ${remoteAddress}`));
|
|
459
|
+
return c.text('Forbidden - Extension must be local', 403);
|
|
460
|
+
}
|
|
461
|
+
// 2. Origin Validation: Prevent browser-based attacks (CSRF).
|
|
462
|
+
// Browsers cannot spoof the Origin header, so this ensures the connection
|
|
463
|
+
// is coming from a Chrome Extension, not a malicious website.
|
|
464
|
+
// We accept any chrome-extension:// origin since localhost check already provides security.
|
|
465
|
+
const origin = c.req.header('origin');
|
|
466
|
+
if (!origin || !origin.startsWith('chrome-extension://')) {
|
|
467
|
+
logger?.log(chalk.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`));
|
|
468
|
+
return c.text('Forbidden', 403);
|
|
469
|
+
}
|
|
470
|
+
return next();
|
|
471
|
+
}, upgradeWebSocket(() => {
|
|
472
|
+
return {
|
|
473
|
+
onOpen(_event, ws) {
|
|
474
|
+
if (extensionWs) {
|
|
475
|
+
logger?.log(chalk.yellow('Closing existing extension connection to replace with new one'));
|
|
476
|
+
extensionWs.close(4001, 'Extension Replaced');
|
|
477
|
+
// Clear state from the old connection to prevent leaks
|
|
478
|
+
connectedTargets.clear();
|
|
479
|
+
for (const pending of extensionPendingRequests.values()) {
|
|
480
|
+
pending.reject(new Error('Extension connection replaced'));
|
|
481
|
+
}
|
|
482
|
+
extensionPendingRequests.clear();
|
|
483
|
+
for (const client of playwrightClients.values()) {
|
|
484
|
+
client.ws.close(1000, 'Extension Replaced');
|
|
485
|
+
}
|
|
486
|
+
playwrightClients.clear();
|
|
487
|
+
}
|
|
488
|
+
extensionWs = ws;
|
|
489
|
+
startExtensionPing();
|
|
490
|
+
logger?.log('Extension connected with clean state');
|
|
491
|
+
},
|
|
492
|
+
async onMessage(event, ws) {
|
|
493
|
+
let message;
|
|
494
|
+
try {
|
|
495
|
+
message = JSON.parse(event.data.toString());
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
ws.close(1000, 'Invalid JSON');
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (message.id !== undefined) {
|
|
502
|
+
const pending = extensionPendingRequests.get(message.id);
|
|
503
|
+
if (!pending) {
|
|
504
|
+
logger?.log('Unexpected response with id:', message.id);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
extensionPendingRequests.delete(message.id);
|
|
508
|
+
if (message.error) {
|
|
509
|
+
pending.reject(new Error(message.error));
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
pending.resolve(message.result);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
else if (message.method === 'pong') {
|
|
516
|
+
// Keep-alive response, nothing to do
|
|
517
|
+
}
|
|
518
|
+
else if (message.method === 'log') {
|
|
519
|
+
const { level, args } = message.params;
|
|
520
|
+
const logFn = logger?.[level] || logger?.log;
|
|
521
|
+
const prefix = chalk.yellow(`[Extension] [${level.toUpperCase()}]`);
|
|
522
|
+
logFn?.(prefix, ...args);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
const extensionEvent = message;
|
|
526
|
+
if (extensionEvent.method !== 'forwardCDPEvent') {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const { method, params, sessionId } = extensionEvent.params;
|
|
530
|
+
logCdpMessage({
|
|
531
|
+
direction: 'from-extension',
|
|
532
|
+
method,
|
|
533
|
+
sessionId,
|
|
534
|
+
params,
|
|
535
|
+
});
|
|
536
|
+
const cdpEvent = { method, sessionId, params };
|
|
537
|
+
emitter.emit('cdp:event', { event: cdpEvent, sessionId });
|
|
538
|
+
if (method === 'Target.attachedToTarget') {
|
|
539
|
+
const targetParams = params;
|
|
540
|
+
if (!targetParams.targetInfo.url) {
|
|
541
|
+
logger?.error(chalk.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'), JSON.stringify({ method, params: targetParams, sessionId }));
|
|
542
|
+
}
|
|
543
|
+
logger?.log(chalk.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId }));
|
|
544
|
+
// Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
|
|
545
|
+
const alreadyConnected = connectedTargets.has(targetParams.sessionId);
|
|
546
|
+
// Always update our local state with latest target info
|
|
547
|
+
connectedTargets.set(targetParams.sessionId, {
|
|
548
|
+
sessionId: targetParams.sessionId,
|
|
549
|
+
targetId: targetParams.targetInfo.targetId,
|
|
550
|
+
targetInfo: targetParams.targetInfo,
|
|
551
|
+
});
|
|
552
|
+
// Only forward to Playwright if this is a new target to avoid duplicates
|
|
553
|
+
if (!alreadyConnected) {
|
|
554
|
+
sendToPlaywright({
|
|
555
|
+
message: {
|
|
556
|
+
method: 'Target.attachedToTarget',
|
|
557
|
+
params: targetParams,
|
|
558
|
+
},
|
|
559
|
+
source: 'extension',
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else if (method === 'Target.detachedFromTarget') {
|
|
564
|
+
const detachParams = params;
|
|
565
|
+
connectedTargets.delete(detachParams.sessionId);
|
|
566
|
+
sendToPlaywright({
|
|
567
|
+
message: {
|
|
568
|
+
method: 'Target.detachedFromTarget',
|
|
569
|
+
params: detachParams,
|
|
570
|
+
},
|
|
571
|
+
source: 'extension',
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
else if (method === 'Target.targetCrashed') {
|
|
575
|
+
const crashParams = params;
|
|
576
|
+
for (const [sid, target] of connectedTargets.entries()) {
|
|
577
|
+
if (target.targetId === crashParams.targetId) {
|
|
578
|
+
connectedTargets.delete(sid);
|
|
579
|
+
logger?.log(chalk.red('[Server] Target crashed, removing:'), crashParams.targetId);
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
sendToPlaywright({
|
|
584
|
+
message: {
|
|
585
|
+
method: 'Target.targetCrashed',
|
|
586
|
+
params: crashParams,
|
|
587
|
+
},
|
|
588
|
+
source: 'extension',
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
else if (method === 'Target.targetInfoChanged') {
|
|
592
|
+
const infoParams = params;
|
|
593
|
+
for (const target of connectedTargets.values()) {
|
|
594
|
+
if (target.targetId === infoParams.targetInfo.targetId) {
|
|
595
|
+
target.targetInfo = infoParams.targetInfo;
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
sendToPlaywright({
|
|
600
|
+
message: {
|
|
601
|
+
method: 'Target.targetInfoChanged',
|
|
602
|
+
params: infoParams,
|
|
603
|
+
},
|
|
604
|
+
source: 'extension',
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
else if (method === 'Page.frameNavigated') {
|
|
608
|
+
const frameParams = params;
|
|
609
|
+
if (!frameParams.frame.parentId && sessionId) {
|
|
610
|
+
const target = connectedTargets.get(sessionId);
|
|
611
|
+
if (target) {
|
|
612
|
+
target.targetInfo = {
|
|
613
|
+
...target.targetInfo,
|
|
614
|
+
url: frameParams.frame.url,
|
|
615
|
+
title: frameParams.frame.name || target.targetInfo.title,
|
|
616
|
+
};
|
|
617
|
+
logger?.log(chalk.magenta('[Server] Updated target URL from Page.frameNavigated:'), frameParams.frame.url);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
sendToPlaywright({
|
|
621
|
+
message: {
|
|
622
|
+
sessionId,
|
|
623
|
+
method,
|
|
624
|
+
params,
|
|
625
|
+
},
|
|
626
|
+
source: 'extension',
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
else if (method === 'Page.navigatedWithinDocument') {
|
|
630
|
+
const navParams = params;
|
|
631
|
+
if (sessionId) {
|
|
632
|
+
const target = connectedTargets.get(sessionId);
|
|
633
|
+
if (target) {
|
|
634
|
+
target.targetInfo = {
|
|
635
|
+
...target.targetInfo,
|
|
636
|
+
url: navParams.url,
|
|
637
|
+
};
|
|
638
|
+
logger?.log(chalk.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), navParams.url);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
sendToPlaywright({
|
|
642
|
+
message: {
|
|
643
|
+
sessionId,
|
|
644
|
+
method,
|
|
645
|
+
params,
|
|
646
|
+
},
|
|
647
|
+
source: 'extension',
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
sendToPlaywright({
|
|
652
|
+
message: {
|
|
653
|
+
sessionId,
|
|
654
|
+
method,
|
|
655
|
+
params,
|
|
656
|
+
},
|
|
657
|
+
source: 'extension',
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
onClose(event, ws) {
|
|
663
|
+
logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'}`);
|
|
664
|
+
stopExtensionPing();
|
|
665
|
+
// If this is an old connection closing after we've already established a new one,
|
|
666
|
+
// don't clear the global state
|
|
667
|
+
if (extensionWs && extensionWs !== ws) {
|
|
668
|
+
logger?.log('Old extension connection closed, keeping new one active');
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
for (const pending of extensionPendingRequests.values()) {
|
|
672
|
+
pending.reject(new Error('Extension connection closed'));
|
|
673
|
+
}
|
|
674
|
+
extensionPendingRequests.clear();
|
|
675
|
+
extensionWs = null;
|
|
676
|
+
connectedTargets.clear();
|
|
677
|
+
for (const client of playwrightClients.values()) {
|
|
678
|
+
client.ws.close(1000, 'Extension disconnected');
|
|
679
|
+
}
|
|
680
|
+
playwrightClients.clear();
|
|
681
|
+
},
|
|
682
|
+
onError(event) {
|
|
683
|
+
logger?.error('Extension WebSocket error:', event);
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
}));
|
|
687
|
+
const server = serve({ fetch: app.fetch, port, hostname: host });
|
|
688
|
+
injectWebSocket(server);
|
|
689
|
+
const wsHost = `ws://${host}:${port}`;
|
|
690
|
+
const cdpEndpoint = `${wsHost}/cdp`;
|
|
691
|
+
const extensionEndpoint = `${wsHost}/extension`;
|
|
692
|
+
logger?.log('CDP relay server started');
|
|
693
|
+
logger?.log('Host:', host);
|
|
694
|
+
logger?.log('Port:', port);
|
|
695
|
+
logger?.log('Extension endpoint:', extensionEndpoint);
|
|
696
|
+
logger?.log('CDP endpoint:', cdpEndpoint);
|
|
697
|
+
return {
|
|
698
|
+
close() {
|
|
699
|
+
for (const client of playwrightClients.values()) {
|
|
700
|
+
client.ws.close(1000, 'Server stopped');
|
|
701
|
+
}
|
|
702
|
+
playwrightClients.clear();
|
|
703
|
+
extensionWs?.close(1000, 'Server stopped');
|
|
704
|
+
server.close();
|
|
705
|
+
emitter.removeAllListeners();
|
|
706
|
+
},
|
|
707
|
+
on(event, listener) {
|
|
708
|
+
emitter.on(event, listener);
|
|
709
|
+
},
|
|
710
|
+
off(event, listener) {
|
|
711
|
+
emitter.off(event, listener);
|
|
712
|
+
},
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
//# sourceMappingURL=cdp-relay.js.map
|