From 1c822e0777f43f0d56f99a1962159cb7eca301b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 25 Jun 2026 15:55:16 +0200 Subject: [PATCH] fix: connect React Native CDP targets --- packages/agent-cdp/README.md | 3 + packages/agent-cdp/skills/core.md | 7 ++ .../agent-cdp/src/__tests__/transport.test.ts | 72 +++++++++++++++++++ packages/agent-cdp/src/transport.ts | 20 +++++- 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/agent-cdp/src/__tests__/transport.test.ts diff --git a/packages/agent-cdp/README.md b/packages/agent-cdp/README.md index b704e93..c1768f5 100644 --- a/packages/agent-cdp/README.md +++ b/packages/agent-cdp/README.md @@ -71,6 +71,7 @@ React Native (example Metro URL): ```sh agent-cdp target list --url http://127.0.0.1:8081 +agent-cdp target select ``` Node.js (example default inspect port after starting your app with `node --inspect …`): @@ -100,6 +101,8 @@ agent-cdp target clear - **Memory allocation timeline** — `memory allocation-timeline` commands for DevTools-style heap allocation timeline capture, bucket summaries, linked final snapshot analysis, and raw artifact export - **CPU profiling** — `profile cpu` commands to record CPU profiles, list sessions, hotspots, stacks, diffs, and optional source map help +React Native targets can expose a smaller CDP surface than Chrome. For JS leak checks, prefer `memory usage sample --gc` for a quick heap signal, then use `memory snapshot capture --gc`, `memory snapshot diff`, and `memory snapshot leak-triplet` to confirm retained objects and inspect retainers. + **4. Stop the daemon** ```sh diff --git a/packages/agent-cdp/skills/core.md b/packages/agent-cdp/skills/core.md index 26b5aeb..d85df86 100644 --- a/packages/agent-cdp/skills/core.md +++ b/packages/agent-cdp/skills/core.md @@ -68,6 +68,10 @@ Requirements: Metro exposes multiple targets (JS runtime, Hermes debugger, etc.). Pick the one labelled with your app name or `"React Native"` in the target list. +React Native and Hermes targets may implement only part of CDP. If a memory, +trace, or performance command reports an unsupported method, keep the target +selected and switch to the supported memory workflow: `memory usage sample --gc` +for a quick signal, then heap snapshots/diffs for retained object proof. ### Rozenite agent tools @@ -180,6 +184,9 @@ agent-cdp memory snapshot leak-triplet --baseline 1 --action 2 --cleanup 3 Use `--gc` before capturing to force a garbage collection so only truly retained objects appear in the snapshot. +For React Native/Hermes targets, prefer this snapshot-based workflow when +confirming leaks because browser-only `Memory.*` or `Performance.*` methods may +not be available. ## JS heap usage monitor diff --git a/packages/agent-cdp/src/__tests__/transport.test.ts b/packages/agent-cdp/src/__tests__/transport.test.ts new file mode 100644 index 0000000..a36dda3 --- /dev/null +++ b/packages/agent-cdp/src/__tests__/transport.test.ts @@ -0,0 +1,72 @@ +import type { TargetDescriptor } from "@agent-cdp/protocol"; + +import { WebSocketCdpTransport } from "../transport.js"; + +const { webSocketCalls } = vi.hoisted(() => ({ + webSocketCalls: [] as Array<{ url: string; options: unknown }>, +})); + +vi.mock("ws", () => { + class MockWebSocket { + static readonly OPEN = 1; + readonly readyState = MockWebSocket.OPEN; + + constructor(url: string, options?: unknown) { + webSocketCalls.push({ url, options }); + } + + once(event: string, listener: () => void): void { + if (event === "open") { + queueMicrotask(listener); + } + } + + on(): void {} + close(): void {} + send(): void {} + } + + return { default: MockWebSocket }; +}); + +describe("WebSocketCdpTransport", () => { + beforeEach(() => { + webSocketCalls.length = 0; + }); + + it("connects to chrome targets without custom websocket headers", async () => { + await new WebSocketCdpTransport(makeTarget({ kind: "chrome", sourceUrl: "http://127.0.0.1:9222" })).connect(); + + expect(webSocketCalls).toEqual([{ url: "ws://127.0.0.1:9222/devtools/page/1", options: undefined }]); + }); + + it("sends the Metro discovery origin when connecting to React Native targets", async () => { + await new WebSocketCdpTransport( + makeTarget({ + kind: "react-native", + sourceUrl: "http://127.0.0.1:8081", + webSocketDebuggerUrl: "ws://127.0.0.1:8081/inspector/debug?device=1&page=2", + }), + ).connect(); + + expect(webSocketCalls).toEqual([ + { + url: "ws://127.0.0.1:8081/inspector/debug?device=1&page=2", + options: { headers: { Origin: "http://127.0.0.1:8081" } }, + }, + ]); + }); +}); + +function makeTarget(overrides: Partial): TargetDescriptor { + return { + id: "target-1", + rawId: "page-1", + title: "Example", + kind: "chrome", + description: "Example target", + webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/1", + sourceUrl: "http://127.0.0.1:9222", + ...overrides, + }; +} diff --git a/packages/agent-cdp/src/transport.ts b/packages/agent-cdp/src/transport.ts index a878b3f..0ed4187 100644 --- a/packages/agent-cdp/src/transport.ts +++ b/packages/agent-cdp/src/transport.ts @@ -1,4 +1,5 @@ import WebSocket from "ws"; +import type { ClientOptions } from "ws"; import type { CdpEventMessage, CdpTransport, TargetDescriptor } from "./types.js"; @@ -6,6 +7,23 @@ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function getReactNativeWebSocketOptions(target: TargetDescriptor): ClientOptions | undefined { + if (target.kind !== "react-native") { + return undefined; + } + + const origin = getUrlOrigin(target.sourceUrl) ?? getUrlOrigin(target.webSocketDebuggerUrl.replace(/^ws/, "http")); + return origin ? { headers: { Origin: origin } } : undefined; +} + +function getUrlOrigin(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + export class WebSocketCdpTransport implements CdpTransport { private readonly listeners = new Set<(message: CdpEventMessage) => void>(); private readonly pending = new Map void; reject: (reason: Error) => void }>(); @@ -20,7 +38,7 @@ export class WebSocketCdpTransport implements CdpTransport { } await new Promise((resolve, reject) => { - const socket = new WebSocket(this.target.webSocketDebuggerUrl); + const socket = new WebSocket(this.target.webSocketDebuggerUrl, getReactNativeWebSocketOptions(this.target)); socket.once("open", () => { this.socket = socket;