Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/agent-cdp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target-id>
```

Node.js (example default inspect port after starting your app with `node --inspect …`):
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/agent-cdp/skills/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
72 changes: 72 additions & 0 deletions packages/agent-cdp/src/__tests__/transport.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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,
};
}
20 changes: 19 additions & 1 deletion packages/agent-cdp/src/transport.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import WebSocket from "ws";
import type { ClientOptions } from "ws";

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

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<number, { resolve: (value: unknown) => void; reject: (reason: Error) => void }>();
Expand All @@ -20,7 +38,7 @@ export class WebSocketCdpTransport implements CdpTransport {
}

await new Promise<void>((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;
Expand Down
Loading