@canivel/ralph 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/.agents/ralph/PROMPT_build.md +126 -0
- package/.agents/ralph/agents.sh +15 -0
- package/.agents/ralph/config.sh +25 -0
- package/.agents/ralph/log-activity.sh +15 -0
- package/.agents/ralph/loop.sh +1001 -0
- package/.agents/ralph/references/CONTEXT_ENGINEERING.md +126 -0
- package/.agents/ralph/references/GUARDRAILS.md +174 -0
- package/AGENTS.md +20 -0
- package/README.md +266 -0
- package/bin/ralph +766 -0
- package/diagram.svg +55 -0
- package/examples/commands.md +46 -0
- package/package.json +39 -0
- package/ralph.webp +0 -0
- package/skills/commit/SKILL.md +219 -0
- package/skills/commit/references/commit_examples.md +292 -0
- package/skills/dev-browser/SKILL.md +211 -0
- package/skills/dev-browser/bun.lock +443 -0
- package/skills/dev-browser/package-lock.json +2988 -0
- package/skills/dev-browser/package.json +31 -0
- package/skills/dev-browser/references/scraping.md +155 -0
- package/skills/dev-browser/scripts/start-relay.ts +32 -0
- package/skills/dev-browser/scripts/start-server.ts +117 -0
- package/skills/dev-browser/server.sh +24 -0
- package/skills/dev-browser/src/client.ts +474 -0
- package/skills/dev-browser/src/index.ts +287 -0
- package/skills/dev-browser/src/relay.ts +731 -0
- package/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts +223 -0
- package/skills/dev-browser/src/snapshot/browser-script.ts +877 -0
- package/skills/dev-browser/src/snapshot/index.ts +14 -0
- package/skills/dev-browser/src/snapshot/inject.ts +13 -0
- package/skills/dev-browser/src/types.ts +34 -0
- package/skills/dev-browser/tsconfig.json +36 -0
- package/skills/dev-browser/vitest.config.ts +12 -0
- package/skills/prd/SKILL.md +235 -0
- package/tests/agent-loops.mjs +79 -0
- package/tests/agent-ping.mjs +39 -0
- package/tests/audit.md +56 -0
- package/tests/cli-smoke.mjs +47 -0
- package/tests/real-agents.mjs +127 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Relay Server for Chrome Extension mode
|
|
3
|
+
*
|
|
4
|
+
* This server acts as a bridge between Playwright clients and a Chrome extension.
|
|
5
|
+
* Instead of launching a browser, it waits for the extension to connect and
|
|
6
|
+
* forwards CDP commands/events between them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { serve } from "@hono/node-server";
|
|
11
|
+
import { createNodeWebSocket } from "@hono/node-ws";
|
|
12
|
+
import type { WSContext } from "hono/ws";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Types
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
export interface RelayOptions {
|
|
19
|
+
port?: number;
|
|
20
|
+
host?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RelayServer {
|
|
24
|
+
wsEndpoint: string;
|
|
25
|
+
port: number;
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TargetInfo {
|
|
30
|
+
targetId: string;
|
|
31
|
+
type: string;
|
|
32
|
+
title: string;
|
|
33
|
+
url: string;
|
|
34
|
+
attached: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ConnectedTarget {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
targetId: string;
|
|
40
|
+
targetInfo: TargetInfo;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PlaywrightClient {
|
|
44
|
+
id: string;
|
|
45
|
+
ws: WSContext;
|
|
46
|
+
knownTargets: Set<string>; // targetIds this client has received attachedToTarget for
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Message types for extension communication
|
|
50
|
+
interface ExtensionCommandMessage {
|
|
51
|
+
id: number;
|
|
52
|
+
method: "forwardCDPCommand";
|
|
53
|
+
params: {
|
|
54
|
+
method: string;
|
|
55
|
+
params?: Record<string, unknown>;
|
|
56
|
+
sessionId?: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ExtensionResponseMessage {
|
|
61
|
+
id: number;
|
|
62
|
+
result?: unknown;
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ExtensionEventMessage {
|
|
67
|
+
method: "forwardCDPEvent";
|
|
68
|
+
params: {
|
|
69
|
+
method: string;
|
|
70
|
+
params?: Record<string, unknown>;
|
|
71
|
+
sessionId?: string;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ExtensionMessage =
|
|
76
|
+
| ExtensionResponseMessage
|
|
77
|
+
| ExtensionEventMessage
|
|
78
|
+
| { method: "log"; params: { level: string; args: string[] } };
|
|
79
|
+
|
|
80
|
+
// CDP message types
|
|
81
|
+
interface CDPCommand {
|
|
82
|
+
id: number;
|
|
83
|
+
method: string;
|
|
84
|
+
params?: Record<string, unknown>;
|
|
85
|
+
sessionId?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface CDPResponse {
|
|
89
|
+
id: number;
|
|
90
|
+
sessionId?: string;
|
|
91
|
+
result?: unknown;
|
|
92
|
+
error?: { message: string };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface CDPEvent {
|
|
96
|
+
method: string;
|
|
97
|
+
sessionId?: string;
|
|
98
|
+
params?: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Relay Server Implementation
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
export async function serveRelay(options: RelayOptions = {}): Promise<RelayServer> {
|
|
106
|
+
const port = options.port ?? 9222;
|
|
107
|
+
const host = options.host ?? "127.0.0.1";
|
|
108
|
+
|
|
109
|
+
// State
|
|
110
|
+
const connectedTargets = new Map<string, ConnectedTarget>();
|
|
111
|
+
const namedPages = new Map<string, string>(); // name -> sessionId
|
|
112
|
+
const playwrightClients = new Map<string, PlaywrightClient>();
|
|
113
|
+
let extensionWs: WSContext | null = null;
|
|
114
|
+
|
|
115
|
+
// Pending requests to extension
|
|
116
|
+
const extensionPendingRequests = new Map<
|
|
117
|
+
number,
|
|
118
|
+
{
|
|
119
|
+
resolve: (result: unknown) => void;
|
|
120
|
+
reject: (error: Error) => void;
|
|
121
|
+
}
|
|
122
|
+
>();
|
|
123
|
+
let extensionMessageId = 0;
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Helper Functions
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
function log(...args: unknown[]) {
|
|
130
|
+
console.log("[relay]", ...args);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sendToPlaywright(message: CDPResponse | CDPEvent, clientId?: string) {
|
|
134
|
+
const messageStr = JSON.stringify(message);
|
|
135
|
+
|
|
136
|
+
if (clientId) {
|
|
137
|
+
const client = playwrightClients.get(clientId);
|
|
138
|
+
if (client) {
|
|
139
|
+
client.ws.send(messageStr);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Broadcast to all clients
|
|
143
|
+
for (const client of playwrightClients.values()) {
|
|
144
|
+
client.ws.send(messageStr);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Send Target.attachedToTarget event with deduplication.
|
|
151
|
+
* Tracks which targets each client has seen to prevent "Duplicate target" errors.
|
|
152
|
+
*/
|
|
153
|
+
function sendAttachedToTarget(
|
|
154
|
+
target: ConnectedTarget,
|
|
155
|
+
clientId?: string,
|
|
156
|
+
waitingForDebugger = false
|
|
157
|
+
) {
|
|
158
|
+
const event: CDPEvent = {
|
|
159
|
+
method: "Target.attachedToTarget",
|
|
160
|
+
params: {
|
|
161
|
+
sessionId: target.sessionId,
|
|
162
|
+
targetInfo: { ...target.targetInfo, attached: true },
|
|
163
|
+
waitingForDebugger,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (clientId) {
|
|
168
|
+
const client = playwrightClients.get(clientId);
|
|
169
|
+
if (client && !client.knownTargets.has(target.targetId)) {
|
|
170
|
+
client.knownTargets.add(target.targetId);
|
|
171
|
+
client.ws.send(JSON.stringify(event));
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// Broadcast to all clients that don't know about this target yet
|
|
175
|
+
for (const client of playwrightClients.values()) {
|
|
176
|
+
if (!client.knownTargets.has(target.targetId)) {
|
|
177
|
+
client.knownTargets.add(target.targetId);
|
|
178
|
+
client.ws.send(JSON.stringify(event));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function sendToExtension({
|
|
185
|
+
method,
|
|
186
|
+
params,
|
|
187
|
+
timeout = 30000,
|
|
188
|
+
}: {
|
|
189
|
+
method: string;
|
|
190
|
+
params?: Record<string, unknown>;
|
|
191
|
+
timeout?: number;
|
|
192
|
+
}): Promise<unknown> {
|
|
193
|
+
if (!extensionWs) {
|
|
194
|
+
throw new Error("Extension not connected");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const id = ++extensionMessageId;
|
|
198
|
+
const message = { id, method, params };
|
|
199
|
+
|
|
200
|
+
extensionWs.send(JSON.stringify(message));
|
|
201
|
+
|
|
202
|
+
return new Promise((resolve, reject) => {
|
|
203
|
+
const timeoutId = setTimeout(() => {
|
|
204
|
+
extensionPendingRequests.delete(id);
|
|
205
|
+
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`));
|
|
206
|
+
}, timeout);
|
|
207
|
+
|
|
208
|
+
extensionPendingRequests.set(id, {
|
|
209
|
+
resolve: (result) => {
|
|
210
|
+
clearTimeout(timeoutId);
|
|
211
|
+
resolve(result);
|
|
212
|
+
},
|
|
213
|
+
reject: (error) => {
|
|
214
|
+
clearTimeout(timeoutId);
|
|
215
|
+
reject(error);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function routeCdpCommand({
|
|
222
|
+
method,
|
|
223
|
+
params,
|
|
224
|
+
sessionId,
|
|
225
|
+
}: {
|
|
226
|
+
method: string;
|
|
227
|
+
params?: Record<string, unknown>;
|
|
228
|
+
sessionId?: string;
|
|
229
|
+
}): Promise<unknown> {
|
|
230
|
+
// Handle some CDP commands locally
|
|
231
|
+
switch (method) {
|
|
232
|
+
case "Browser.getVersion":
|
|
233
|
+
return {
|
|
234
|
+
protocolVersion: "1.3",
|
|
235
|
+
product: "Chrome/Extension-Bridge",
|
|
236
|
+
revision: "1.0.0",
|
|
237
|
+
userAgent: "dev-browser-relay/1.0.0",
|
|
238
|
+
jsVersion: "V8",
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
case "Browser.setDownloadBehavior":
|
|
242
|
+
return {};
|
|
243
|
+
|
|
244
|
+
case "Target.setAutoAttach":
|
|
245
|
+
if (sessionId) {
|
|
246
|
+
break; // Forward to extension for child frames
|
|
247
|
+
}
|
|
248
|
+
return {};
|
|
249
|
+
|
|
250
|
+
case "Target.setDiscoverTargets":
|
|
251
|
+
return {};
|
|
252
|
+
|
|
253
|
+
case "Target.attachToBrowserTarget":
|
|
254
|
+
// Browser-level session - return a fake session since we only proxy tabs
|
|
255
|
+
return { sessionId: "browser" };
|
|
256
|
+
|
|
257
|
+
case "Target.detachFromTarget":
|
|
258
|
+
// If detaching from our fake "browser" session, just return success
|
|
259
|
+
if (sessionId === "browser" || params?.sessionId === "browser") {
|
|
260
|
+
return {};
|
|
261
|
+
}
|
|
262
|
+
// Otherwise forward to extension
|
|
263
|
+
break;
|
|
264
|
+
|
|
265
|
+
case "Target.attachToTarget": {
|
|
266
|
+
const targetId = params?.targetId as string;
|
|
267
|
+
if (!targetId) {
|
|
268
|
+
throw new Error("targetId is required for Target.attachToTarget");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const target of connectedTargets.values()) {
|
|
272
|
+
if (target.targetId === targetId) {
|
|
273
|
+
return { sessionId: target.sessionId };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
throw new Error(`Target ${targetId} not found in connected targets`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case "Target.getTargetInfo": {
|
|
281
|
+
const targetId = params?.targetId as string;
|
|
282
|
+
|
|
283
|
+
if (targetId) {
|
|
284
|
+
for (const target of connectedTargets.values()) {
|
|
285
|
+
if (target.targetId === targetId) {
|
|
286
|
+
return { targetInfo: target.targetInfo };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (sessionId) {
|
|
292
|
+
const target = connectedTargets.get(sessionId);
|
|
293
|
+
if (target) {
|
|
294
|
+
return { targetInfo: target.targetInfo };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Return first target if no specific one requested
|
|
299
|
+
const firstTarget = Array.from(connectedTargets.values())[0];
|
|
300
|
+
return { targetInfo: firstTarget?.targetInfo };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case "Target.getTargets":
|
|
304
|
+
return {
|
|
305
|
+
targetInfos: Array.from(connectedTargets.values()).map((t) => ({
|
|
306
|
+
...t.targetInfo,
|
|
307
|
+
attached: true,
|
|
308
|
+
})),
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
case "Target.createTarget":
|
|
312
|
+
case "Target.closeTarget":
|
|
313
|
+
// Forward to extension
|
|
314
|
+
return await sendToExtension({
|
|
315
|
+
method: "forwardCDPCommand",
|
|
316
|
+
params: { method, params },
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Forward all other commands to extension
|
|
321
|
+
return await sendToExtension({
|
|
322
|
+
method: "forwardCDPCommand",
|
|
323
|
+
params: { sessionId, method, params },
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ============================================================================
|
|
328
|
+
// HTTP/WebSocket Server
|
|
329
|
+
// ============================================================================
|
|
330
|
+
|
|
331
|
+
const app = new Hono();
|
|
332
|
+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
|
333
|
+
|
|
334
|
+
// Health check / server info
|
|
335
|
+
app.get("/", (c) => {
|
|
336
|
+
return c.json({
|
|
337
|
+
wsEndpoint: `ws://${host}:${port}/cdp`,
|
|
338
|
+
extensionConnected: extensionWs !== null,
|
|
339
|
+
mode: "extension",
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// List named pages
|
|
344
|
+
app.get("/pages", (c) => {
|
|
345
|
+
return c.json({
|
|
346
|
+
pages: Array.from(namedPages.keys()),
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Get or create a named page
|
|
351
|
+
app.post("/pages", async (c) => {
|
|
352
|
+
const body = await c.req.json();
|
|
353
|
+
const name = body.name as string;
|
|
354
|
+
|
|
355
|
+
if (!name) {
|
|
356
|
+
return c.json({ error: "name is required" }, 400);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Check if page already exists by name
|
|
360
|
+
const existingSessionId = namedPages.get(name);
|
|
361
|
+
if (existingSessionId) {
|
|
362
|
+
const target = connectedTargets.get(existingSessionId);
|
|
363
|
+
if (target) {
|
|
364
|
+
// Activate the tab so it becomes the active tab
|
|
365
|
+
await sendToExtension({
|
|
366
|
+
method: "forwardCDPCommand",
|
|
367
|
+
params: {
|
|
368
|
+
method: "Target.activateTarget",
|
|
369
|
+
params: { targetId: target.targetId },
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
return c.json({
|
|
373
|
+
wsEndpoint: `ws://${host}:${port}/cdp`,
|
|
374
|
+
name,
|
|
375
|
+
targetId: target.targetId,
|
|
376
|
+
url: target.targetInfo.url,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
// Session no longer valid, remove it
|
|
380
|
+
namedPages.delete(name);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Create a new tab
|
|
384
|
+
if (!extensionWs) {
|
|
385
|
+
return c.json({ error: "Extension not connected" }, 503);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const result = (await sendToExtension({
|
|
390
|
+
method: "forwardCDPCommand",
|
|
391
|
+
params: { method: "Target.createTarget", params: { url: "about:blank" } },
|
|
392
|
+
})) as { targetId: string };
|
|
393
|
+
|
|
394
|
+
// Wait for Target.attachedToTarget event to register the new target
|
|
395
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
396
|
+
|
|
397
|
+
// Find and name the new target
|
|
398
|
+
for (const [sessionId, target] of connectedTargets) {
|
|
399
|
+
if (target.targetId === result.targetId) {
|
|
400
|
+
namedPages.set(name, sessionId);
|
|
401
|
+
// Activate the tab so it becomes the active tab
|
|
402
|
+
await sendToExtension({
|
|
403
|
+
method: "forwardCDPCommand",
|
|
404
|
+
params: {
|
|
405
|
+
method: "Target.activateTarget",
|
|
406
|
+
params: { targetId: target.targetId },
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
return c.json({
|
|
410
|
+
wsEndpoint: `ws://${host}:${port}/cdp`,
|
|
411
|
+
name,
|
|
412
|
+
targetId: target.targetId,
|
|
413
|
+
url: target.targetInfo.url,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
throw new Error("Target created but not found in registry");
|
|
419
|
+
} catch (err) {
|
|
420
|
+
log("Error creating tab:", err);
|
|
421
|
+
return c.json({ error: (err as Error).message }, 500);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// Delete a named page (removes the name, doesn't close the tab)
|
|
426
|
+
app.delete("/pages/:name", (c) => {
|
|
427
|
+
const name = c.req.param("name");
|
|
428
|
+
const deleted = namedPages.delete(name);
|
|
429
|
+
return c.json({ success: deleted });
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ============================================================================
|
|
433
|
+
// Playwright Client WebSocket
|
|
434
|
+
// ============================================================================
|
|
435
|
+
|
|
436
|
+
app.get(
|
|
437
|
+
"/cdp/:clientId?",
|
|
438
|
+
upgradeWebSocket((c) => {
|
|
439
|
+
const clientId =
|
|
440
|
+
c.req.param("clientId") || `client-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
onOpen(_event, ws) {
|
|
444
|
+
if (playwrightClients.has(clientId)) {
|
|
445
|
+
log(`Rejecting duplicate client ID: ${clientId}`);
|
|
446
|
+
ws.close(1000, "Client ID already connected");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
playwrightClients.set(clientId, { id: clientId, ws, knownTargets: new Set() });
|
|
451
|
+
log(`Playwright client connected: ${clientId}`);
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
async onMessage(event, _ws) {
|
|
455
|
+
let message: CDPCommand;
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
message = JSON.parse(event.data.toString());
|
|
459
|
+
} catch {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const { id, sessionId, method, params } = message;
|
|
464
|
+
|
|
465
|
+
if (!extensionWs) {
|
|
466
|
+
sendToPlaywright(
|
|
467
|
+
{
|
|
468
|
+
id,
|
|
469
|
+
sessionId,
|
|
470
|
+
error: { message: "Extension not connected" },
|
|
471
|
+
},
|
|
472
|
+
clientId
|
|
473
|
+
);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const result = await routeCdpCommand({ method, params, sessionId });
|
|
479
|
+
|
|
480
|
+
// After Target.setAutoAttach, send attachedToTarget for existing targets
|
|
481
|
+
// Uses deduplication to prevent "Duplicate target" errors
|
|
482
|
+
if (method === "Target.setAutoAttach" && !sessionId) {
|
|
483
|
+
for (const target of connectedTargets.values()) {
|
|
484
|
+
sendAttachedToTarget(target, clientId);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// After Target.setDiscoverTargets, send targetCreated events
|
|
489
|
+
if (
|
|
490
|
+
method === "Target.setDiscoverTargets" &&
|
|
491
|
+
(params as { discover?: boolean })?.discover
|
|
492
|
+
) {
|
|
493
|
+
for (const target of connectedTargets.values()) {
|
|
494
|
+
sendToPlaywright(
|
|
495
|
+
{
|
|
496
|
+
method: "Target.targetCreated",
|
|
497
|
+
params: {
|
|
498
|
+
targetInfo: { ...target.targetInfo, attached: true },
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
clientId
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// After Target.attachToTarget, send attachedToTarget event (with deduplication)
|
|
507
|
+
if (
|
|
508
|
+
method === "Target.attachToTarget" &&
|
|
509
|
+
(result as { sessionId?: string })?.sessionId
|
|
510
|
+
) {
|
|
511
|
+
const targetId = params?.targetId as string;
|
|
512
|
+
const target = Array.from(connectedTargets.values()).find(
|
|
513
|
+
(t) => t.targetId === targetId
|
|
514
|
+
);
|
|
515
|
+
if (target) {
|
|
516
|
+
sendAttachedToTarget(target, clientId);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
sendToPlaywright({ id, sessionId, result }, clientId);
|
|
521
|
+
} catch (e) {
|
|
522
|
+
log("Error handling CDP command:", method, e);
|
|
523
|
+
sendToPlaywright(
|
|
524
|
+
{
|
|
525
|
+
id,
|
|
526
|
+
sessionId,
|
|
527
|
+
error: { message: (e as Error).message },
|
|
528
|
+
},
|
|
529
|
+
clientId
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
onClose() {
|
|
535
|
+
playwrightClients.delete(clientId);
|
|
536
|
+
log(`Playwright client disconnected: ${clientId}`);
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
onError(event) {
|
|
540
|
+
log(`Playwright WebSocket error [${clientId}]:`, event);
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
})
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
// ============================================================================
|
|
547
|
+
// Extension WebSocket
|
|
548
|
+
// ============================================================================
|
|
549
|
+
|
|
550
|
+
app.get(
|
|
551
|
+
"/extension",
|
|
552
|
+
upgradeWebSocket(() => {
|
|
553
|
+
return {
|
|
554
|
+
onOpen(_event, ws) {
|
|
555
|
+
if (extensionWs) {
|
|
556
|
+
log("Closing existing extension connection");
|
|
557
|
+
extensionWs.close(4001, "Extension Replaced");
|
|
558
|
+
|
|
559
|
+
// Clear state
|
|
560
|
+
connectedTargets.clear();
|
|
561
|
+
namedPages.clear();
|
|
562
|
+
for (const pending of extensionPendingRequests.values()) {
|
|
563
|
+
pending.reject(new Error("Extension connection replaced"));
|
|
564
|
+
}
|
|
565
|
+
extensionPendingRequests.clear();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
extensionWs = ws;
|
|
569
|
+
log("Extension connected");
|
|
570
|
+
},
|
|
571
|
+
|
|
572
|
+
async onMessage(event, ws) {
|
|
573
|
+
let message: ExtensionMessage;
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
message = JSON.parse(event.data.toString());
|
|
577
|
+
} catch {
|
|
578
|
+
ws.close(1000, "Invalid JSON");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Handle response to our request
|
|
583
|
+
if ("id" in message && typeof message.id === "number") {
|
|
584
|
+
const pending = extensionPendingRequests.get(message.id);
|
|
585
|
+
if (!pending) {
|
|
586
|
+
log("Unexpected response with id:", message.id);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
extensionPendingRequests.delete(message.id);
|
|
591
|
+
|
|
592
|
+
if ((message as ExtensionResponseMessage).error) {
|
|
593
|
+
pending.reject(new Error((message as ExtensionResponseMessage).error));
|
|
594
|
+
} else {
|
|
595
|
+
pending.resolve((message as ExtensionResponseMessage).result);
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Handle log messages
|
|
601
|
+
if ("method" in message && message.method === "log") {
|
|
602
|
+
const { level, args } = message.params;
|
|
603
|
+
console.log(`[extension:${level}]`, ...args);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Handle CDP events from extension
|
|
608
|
+
if ("method" in message && message.method === "forwardCDPEvent") {
|
|
609
|
+
const eventMsg = message as ExtensionEventMessage;
|
|
610
|
+
const { method, params, sessionId } = eventMsg.params;
|
|
611
|
+
|
|
612
|
+
// Handle target lifecycle events
|
|
613
|
+
if (method === "Target.attachedToTarget") {
|
|
614
|
+
const targetParams = params as {
|
|
615
|
+
sessionId: string;
|
|
616
|
+
targetInfo: TargetInfo;
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const target: ConnectedTarget = {
|
|
620
|
+
sessionId: targetParams.sessionId,
|
|
621
|
+
targetId: targetParams.targetInfo.targetId,
|
|
622
|
+
targetInfo: targetParams.targetInfo,
|
|
623
|
+
};
|
|
624
|
+
connectedTargets.set(targetParams.sessionId, target);
|
|
625
|
+
|
|
626
|
+
log(`Target attached: ${targetParams.targetInfo.url} (${targetParams.sessionId})`);
|
|
627
|
+
|
|
628
|
+
// Use deduplication helper - only sends to clients that don't know about this target
|
|
629
|
+
sendAttachedToTarget(target);
|
|
630
|
+
} else if (method === "Target.detachedFromTarget") {
|
|
631
|
+
const detachParams = params as { sessionId: string };
|
|
632
|
+
connectedTargets.delete(detachParams.sessionId);
|
|
633
|
+
|
|
634
|
+
// Also remove any name mapping
|
|
635
|
+
for (const [name, sid] of namedPages) {
|
|
636
|
+
if (sid === detachParams.sessionId) {
|
|
637
|
+
namedPages.delete(name);
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
log(`Target detached: ${detachParams.sessionId}`);
|
|
643
|
+
|
|
644
|
+
sendToPlaywright({
|
|
645
|
+
method: "Target.detachedFromTarget",
|
|
646
|
+
params: detachParams,
|
|
647
|
+
});
|
|
648
|
+
} else if (method === "Target.targetInfoChanged") {
|
|
649
|
+
const infoParams = params as { targetInfo: TargetInfo };
|
|
650
|
+
for (const target of connectedTargets.values()) {
|
|
651
|
+
if (target.targetId === infoParams.targetInfo.targetId) {
|
|
652
|
+
target.targetInfo = infoParams.targetInfo;
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
sendToPlaywright({
|
|
658
|
+
method: "Target.targetInfoChanged",
|
|
659
|
+
params: infoParams,
|
|
660
|
+
});
|
|
661
|
+
} else {
|
|
662
|
+
// Forward other CDP events to Playwright
|
|
663
|
+
sendToPlaywright({
|
|
664
|
+
sessionId,
|
|
665
|
+
method,
|
|
666
|
+
params,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
onClose(_event, ws) {
|
|
673
|
+
if (extensionWs && extensionWs !== ws) {
|
|
674
|
+
log("Old extension connection closed");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
log("Extension disconnected");
|
|
679
|
+
|
|
680
|
+
for (const pending of extensionPendingRequests.values()) {
|
|
681
|
+
pending.reject(new Error("Extension connection closed"));
|
|
682
|
+
}
|
|
683
|
+
extensionPendingRequests.clear();
|
|
684
|
+
|
|
685
|
+
extensionWs = null;
|
|
686
|
+
connectedTargets.clear();
|
|
687
|
+
namedPages.clear();
|
|
688
|
+
|
|
689
|
+
// Close all Playwright clients
|
|
690
|
+
for (const client of playwrightClients.values()) {
|
|
691
|
+
client.ws.close(1000, "Extension disconnected");
|
|
692
|
+
}
|
|
693
|
+
playwrightClients.clear();
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
onError(event) {
|
|
697
|
+
log("Extension WebSocket error:", event);
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
})
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
// ============================================================================
|
|
704
|
+
// Start Server
|
|
705
|
+
// ============================================================================
|
|
706
|
+
|
|
707
|
+
const server = serve({ fetch: app.fetch, port, hostname: host });
|
|
708
|
+
injectWebSocket(server);
|
|
709
|
+
|
|
710
|
+
const wsEndpoint = `ws://${host}:${port}/cdp`;
|
|
711
|
+
|
|
712
|
+
log("CDP relay server started");
|
|
713
|
+
log(` HTTP: http://${host}:${port}`);
|
|
714
|
+
log(` CDP endpoint: ${wsEndpoint}`);
|
|
715
|
+
log(` Extension endpoint: ws://${host}:${port}/extension`);
|
|
716
|
+
log("");
|
|
717
|
+
log("Waiting for extension to connect...");
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
wsEndpoint,
|
|
721
|
+
port,
|
|
722
|
+
async stop() {
|
|
723
|
+
for (const client of playwrightClients.values()) {
|
|
724
|
+
client.ws.close(1000, "Server stopped");
|
|
725
|
+
}
|
|
726
|
+
playwrightClients.clear();
|
|
727
|
+
extensionWs?.close(1000, "Server stopped");
|
|
728
|
+
server.close();
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
}
|