@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.
Files changed (40) hide show
  1. package/.agents/ralph/PROMPT_build.md +126 -0
  2. package/.agents/ralph/agents.sh +15 -0
  3. package/.agents/ralph/config.sh +25 -0
  4. package/.agents/ralph/log-activity.sh +15 -0
  5. package/.agents/ralph/loop.sh +1001 -0
  6. package/.agents/ralph/references/CONTEXT_ENGINEERING.md +126 -0
  7. package/.agents/ralph/references/GUARDRAILS.md +174 -0
  8. package/AGENTS.md +20 -0
  9. package/README.md +266 -0
  10. package/bin/ralph +766 -0
  11. package/diagram.svg +55 -0
  12. package/examples/commands.md +46 -0
  13. package/package.json +39 -0
  14. package/ralph.webp +0 -0
  15. package/skills/commit/SKILL.md +219 -0
  16. package/skills/commit/references/commit_examples.md +292 -0
  17. package/skills/dev-browser/SKILL.md +211 -0
  18. package/skills/dev-browser/bun.lock +443 -0
  19. package/skills/dev-browser/package-lock.json +2988 -0
  20. package/skills/dev-browser/package.json +31 -0
  21. package/skills/dev-browser/references/scraping.md +155 -0
  22. package/skills/dev-browser/scripts/start-relay.ts +32 -0
  23. package/skills/dev-browser/scripts/start-server.ts +117 -0
  24. package/skills/dev-browser/server.sh +24 -0
  25. package/skills/dev-browser/src/client.ts +474 -0
  26. package/skills/dev-browser/src/index.ts +287 -0
  27. package/skills/dev-browser/src/relay.ts +731 -0
  28. package/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts +223 -0
  29. package/skills/dev-browser/src/snapshot/browser-script.ts +877 -0
  30. package/skills/dev-browser/src/snapshot/index.ts +14 -0
  31. package/skills/dev-browser/src/snapshot/inject.ts +13 -0
  32. package/skills/dev-browser/src/types.ts +34 -0
  33. package/skills/dev-browser/tsconfig.json +36 -0
  34. package/skills/dev-browser/vitest.config.ts +12 -0
  35. package/skills/prd/SKILL.md +235 -0
  36. package/tests/agent-loops.mjs +79 -0
  37. package/tests/agent-ping.mjs +39 -0
  38. package/tests/audit.md +56 -0
  39. package/tests/cli-smoke.mjs +47 -0
  40. 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
+ }