Skip to content

Commit fb13b5f

Browse files
authored
fix: connect React Native CDP targets (#30)
## End-user impact React Native targets exposed through Metro can now be selected by `agent-cdp` without failing the WebSocket handshake with HTTP 401. This unlocks the existing JS heap usage, heap snapshot, allocation, and leak-triplet workflows for RN/Hermes targets that implement those CDP methods. Users can continue the normal flow: ```sh agent-cdp target list --url http://127.0.0.1:8081 agent-cdp target select <target-id> agent-cdp memory usage sample --gc agent-cdp memory snapshot capture --name baseline --gc ``` ## Compatibility Chrome and Node targets keep the existing WebSocket behavior. Only React Native target descriptors receive an `Origin` header, derived from the discovery URL origin. Existing commands, output formats, daemon state, snapshot artifacts, and saved analysis workflows are unchanged. The docs now call out that React Native/Hermes may expose only a subset of browser CDP and should use heap usage/snapshot workflows when browser-only domains are unsupported. ## Risks The main risk is target-specific: some non-Metro React Native-compatible endpoints might validate Origin differently. The fallback still derives an origin from the WebSocket URL if the discovery source URL is malformed. ## Manual testing Setup used during investigation: - Expo SDK 56 test app running in Expo Go 56.0.3 on iOS simulator - Metro on `http://127.0.0.1:8081` Observed before this fix: `agent-cdp target select <react-native-target>` failed with `Unexpected server response: 401`. Validation performed: - `pnpm --filter agent-cdp exec vitest run src/__tests__/transport.test.ts` - `pnpm --filter agent-cdp typecheck` - `pnpm --filter agent-cdp format` - `pnpm --filter agent-cdp lint` - `pnpm --filter agent-cdp build` - With a temporary local transport patch, `target select` succeeded against Expo Go 56 and memory commands captured JS heap usage, heap snapshots, snapshot diffs, leak-triplet output, retainers, and allocation hotspots.
1 parent 220dfd2 commit fb13b5f

4 files changed

Lines changed: 101 additions & 1 deletion

File tree

packages/agent-cdp/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ React Native (example Metro URL):
7171
7272
```sh
7373
agent-cdp target list --url http://127.0.0.1:8081
74+
agent-cdp target select <target-id>
7475
```
7576
7677
Node.js (example default inspect port after starting your app with `node --inspect …`):
@@ -100,6 +101,8 @@ agent-cdp target clear
100101
- **Memory allocation timeline**`memory allocation-timeline` commands for DevTools-style heap allocation timeline capture, bucket summaries, linked final snapshot analysis, and raw artifact export
101102
- **CPU profiling**`profile cpu` commands to record CPU profiles, list sessions, hotspots, stacks, diffs, and optional source map help
102103
104+
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.
105+
103106
**4. Stop the daemon**
104107
105108
```sh

packages/agent-cdp/skills/core.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ Requirements:
6868
6969
Metro exposes multiple targets (JS runtime, Hermes debugger, etc.). Pick the
7070
one labelled with your app name or `"React Native"` in the target list.
71+
React Native and Hermes targets may implement only part of CDP. If a memory,
72+
trace, or performance command reports an unsupported method, keep the target
73+
selected and switch to the supported memory workflow: `memory usage sample --gc`
74+
for a quick signal, then heap snapshots/diffs for retained object proof.
7175
7276
### Rozenite agent tools
7377
@@ -180,6 +184,9 @@ agent-cdp memory snapshot leak-triplet --baseline 1 --action 2 --cleanup 3
180184
181185
Use `--gc` before capturing to force a garbage collection so only truly
182186
retained objects appear in the snapshot.
187+
For React Native/Hermes targets, prefer this snapshot-based workflow when
188+
confirming leaks because browser-only `Memory.*` or `Performance.*` methods may
189+
not be available.
183190
184191
## JS heap usage monitor
185192
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { TargetDescriptor } from "@agent-cdp/protocol";
2+
3+
import { WebSocketCdpTransport } from "../transport.js";
4+
5+
const { webSocketCalls } = vi.hoisted(() => ({
6+
webSocketCalls: [] as Array<{ url: string; options: unknown }>,
7+
}));
8+
9+
vi.mock("ws", () => {
10+
class MockWebSocket {
11+
static readonly OPEN = 1;
12+
readonly readyState = MockWebSocket.OPEN;
13+
14+
constructor(url: string, options?: unknown) {
15+
webSocketCalls.push({ url, options });
16+
}
17+
18+
once(event: string, listener: () => void): void {
19+
if (event === "open") {
20+
queueMicrotask(listener);
21+
}
22+
}
23+
24+
on(): void {}
25+
close(): void {}
26+
send(): void {}
27+
}
28+
29+
return { default: MockWebSocket };
30+
});
31+
32+
describe("WebSocketCdpTransport", () => {
33+
beforeEach(() => {
34+
webSocketCalls.length = 0;
35+
});
36+
37+
it("connects to chrome targets without custom websocket headers", async () => {
38+
await new WebSocketCdpTransport(makeTarget({ kind: "chrome", sourceUrl: "http://127.0.0.1:9222" })).connect();
39+
40+
expect(webSocketCalls).toEqual([{ url: "ws://127.0.0.1:9222/devtools/page/1", options: undefined }]);
41+
});
42+
43+
it("sends the Metro discovery origin when connecting to React Native targets", async () => {
44+
await new WebSocketCdpTransport(
45+
makeTarget({
46+
kind: "react-native",
47+
sourceUrl: "http://127.0.0.1:8081",
48+
webSocketDebuggerUrl: "ws://127.0.0.1:8081/inspector/debug?device=1&page=2",
49+
}),
50+
).connect();
51+
52+
expect(webSocketCalls).toEqual([
53+
{
54+
url: "ws://127.0.0.1:8081/inspector/debug?device=1&page=2",
55+
options: { headers: { Origin: "http://127.0.0.1:8081" } },
56+
},
57+
]);
58+
});
59+
});
60+
61+
function makeTarget(overrides: Partial<TargetDescriptor>): TargetDescriptor {
62+
return {
63+
id: "target-1",
64+
rawId: "page-1",
65+
title: "Example",
66+
kind: "chrome",
67+
description: "Example target",
68+
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/1",
69+
sourceUrl: "http://127.0.0.1:9222",
70+
...overrides,
71+
};
72+
}

packages/agent-cdp/src/transport.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
import WebSocket from "ws";
2+
import type { ClientOptions } from "ws";
23

34
import type { CdpEventMessage, CdpTransport, TargetDescriptor } from "./types.js";
45

56
function toErrorMessage(error: unknown): string {
67
return error instanceof Error ? error.message : String(error);
78
}
89

10+
function getReactNativeWebSocketOptions(target: TargetDescriptor): ClientOptions | undefined {
11+
if (target.kind !== "react-native") {
12+
return undefined;
13+
}
14+
15+
const origin = getUrlOrigin(target.sourceUrl) ?? getUrlOrigin(target.webSocketDebuggerUrl.replace(/^ws/, "http"));
16+
return origin ? { headers: { Origin: origin } } : undefined;
17+
}
18+
19+
function getUrlOrigin(url: string): string | null {
20+
try {
21+
return new URL(url).origin;
22+
} catch {
23+
return null;
24+
}
25+
}
26+
927
export class WebSocketCdpTransport implements CdpTransport {
1028
private readonly listeners = new Set<(message: CdpEventMessage) => void>();
1129
private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (reason: Error) => void }>();
@@ -20,7 +38,7 @@ export class WebSocketCdpTransport implements CdpTransport {
2038
}
2139

2240
await new Promise<void>((resolve, reject) => {
23-
const socket = new WebSocket(this.target.webSocketDebuggerUrl);
41+
const socket = new WebSocket(this.target.webSocketDebuggerUrl, getReactNativeWebSocketOptions(this.target));
2442

2543
socket.once("open", () => {
2644
this.socket = socket;

0 commit comments

Comments
 (0)