Skip to content
Draft
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
4 changes: 2 additions & 2 deletions packages/docs/docs/features/network.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ Argent removes the bundle traffic and the HMR traffic of Metro from the network
- If the app aborts a library request before the network module of React Native gives the request its ID, the request still runs. The app gets an abort error, but the record shows the finished request and its response.
- If the app does not read the body of a successful Expo `fetch()` response, the record has no body and no size. A response that cannot have a body, for example the response to a `HEAD` request, has the size 0.
- Argent finds the network module of React Native in the list of modules that Metro adds to a development build. Without this list, Argent does not record the requests that a library sends to the network module itself.
- The network log keeps the most recent records only. The limits are 2000 records for the JS runtime layer and 1000 records for Chromium. For native iOS, the limit is 1000 protocol events. A request produces several events, so the native log holds fewer than 1000 requests.
- The network log keeps the most recent records only. The limits are 2000 records for the JS runtime layer and 1000 records for Chromium. For native iOS, the limit is 1000 protocol events. A request produces several events, so the native log holds fewer than 1000 requests. When the native log drops old events, both the request rows and the raw events are marked `truncated`.
- The JS runtime layer does not read a binary response body, for example an image, a font, a PDF file or an `application/octet-stream` body. The record gives the type and the size of the body only. For Chromium, Argent does not show a response body that is not UTF-8 text. The details give the type and the size of this body only.
- The JS runtime layer keeps approximately the first 1 MB of each body. The JS runtime layer also removes the oldest records when their bodies contain more than 50 MB of text.
- For Chromium, Argent reads the active tab. If you change the tab, Argent attaches to the new tab. If the page changed after the request, the body of the response can be unavailable.
- The `NSURLProtocol` layer is available on iOS only. On Android, use the JS runtime layer.
- Argent does not replace the data in the bodies of the requests and of the responses. Protect a network log as you protect other data of the app.
- On the native `NSURLProtocol` layer, Argent does not replace the values of secret headers. The agent sees headers such as `Authorization` and `Cookie` in full. Protect a native network log as you protect other data of the app.
- On the native `NSURLProtocol` layer, the agent gets one short row for each request by default, without headers. The agent can ask for the raw events, which contain the headers. Neither form contains the bodies of responses. The raw events contain the body of a request only if the app does not send the body as a stream, and most iOS requests send it as a stream. When the agent asks for the raw events, Argent does not replace the values of secret headers. The agent sees headers such as `Authorization` and `Cookie` in full. Protect a native network log as you protect other data of the app.

Argent does this with these three tools: `view-network-logs`, `view-network-request-details` and `native-network-logs`. The [tools reference](/docs/reference/tools) contains all tools.
2 changes: 1 addition & 1 deletion packages/docs/docs/reference/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Argent supports physical iPhones; physical iPads are not supported yet. `list-de
| `native-find-views` | Search views by class, accessibility id, label, tag, or `nativeID` |
| `native-view-at-point` | Inspect the deepest visible view at a native window point |
| `native-user-interactable-view-at-point` | Inspect the deepest view at a point that would receive touch input |
| `native-network-logs` | Retrieve requests captured at the native `NSURLProtocol` level |
| `native-network-logs` | Requests captured at the native `NSURLProtocol` level, one row each |

## Chromium and Electron

Expand Down
16 changes: 12 additions & 4 deletions packages/tool-server/src/blueprints/native-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const NON_INJECTABLE_RECOVERY =

// Max consecutive init failures per service instance before it stops retrying.
export const MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS = 3;
const MAX_NETWORK_LOG_ENTRIES = 1000;

/**
* Why an app has no live devtools connection — and so what, if anything,
Expand Down Expand Up @@ -487,6 +488,8 @@ export interface NativeDevtoolsApi {
activateNetworkInspection(bundleId: string): void;
getNetworkLog(bundleId: string): NetworkEvent[];
clearNetworkLog(bundleId: string): void;
/** True once the log dropped its oldest events to stay at MAX_NETWORK_LOG_ENTRIES; cleared with the log. */
networkLogEvicted(bundleId: string): boolean;
getAppState(bundleId: string): Promise<NativeAppState>;
detectFrontmostBundleId(): Promise<string | null>;
queryViewHierarchy(
Expand All @@ -499,6 +502,7 @@ export interface NativeDevtoolsApi {
interface AppConnection {
socket: net.Socket;
networkLog: NetworkEvent[];
networkLogEvicted: boolean;
}

function getNativeDevtoolsSocketPath(udid: string): string {
Expand Down Expand Up @@ -644,7 +648,6 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device
// listen block below, before ensureEnvReady and dispose read it.
const endpoint: IosEndpoint =
transport === "tcp" ? { transport: "tcp" } : { transport: "unix", socketPath };
const MAX_LOG_ENTRIES = 1000;
const connections = new Map<string, AppConnection>();
const pendingRpc = new Map<
number,
Expand Down Expand Up @@ -808,7 +811,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device
existing.socket.destroy();
}

connections.set(bundleId, { socket, networkLog: [] });
connections.set(bundleId, { socket, networkLog: [], networkLogEvicted: false });

if (activatedBundleIds.has(bundleId)) {
socket.write(
Expand All @@ -827,8 +830,9 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device
if (p.method && p.id === undefined) {
const conn = connections.get(bundleId);
if (conn) {
if (conn.networkLog.length >= MAX_LOG_ENTRIES) {
if (conn.networkLog.length >= MAX_NETWORK_LOG_ENTRIES) {
conn.networkLog.shift();
conn.networkLogEvicted = true;
}
conn.networkLog.push({
method: p.method,
Expand Down Expand Up @@ -1087,9 +1091,13 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint<NativeDevtoolsApi, Device

clearNetworkLog: (bundleId) => {
const conn = connections.get(bundleId);
if (conn) conn.networkLog.length = 0;
if (!conn) return;
conn.networkLog.length = 0;
conn.networkLogEvicted = false;
},

networkLogEvicted: (bundleId) => connections.get(bundleId)?.networkLogEvicted ?? false,

async getAppState(bundleId) {
const result = (await sendViewInspectorRpc(bundleId, "Application.getState")) as {
applicationState?: NativeApplicationState;
Expand Down
121 changes: 115 additions & 6 deletions packages/tool-server/src/tools/native-devtools/native-network-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,107 @@ const zodSchema = z.object({
bundleId: z.string().describe("Bundle ID of the app"),
limit: z
.number()
.int()
.nonnegative()
.optional()
.default(50)
.describe("Maximum number of events to return (most recent first)"),
clear: z.boolean().optional().default(false).describe("Clear the log after reading"),
.describe(
"Maximum number of requests (format 'requests') or raw events (format 'events') to return, most recent"
),
format: z
.enum(["requests", "events"])
.optional()
.describe(
"'requests' (default): one compact row per request: method, url, finalUrl, status, mimeType, bytes, durationMs, error. 'events': the raw CDP Network.* events, several per request, with request and response headers. Neither format carries response bodies (dataReceived events hold only chunk sizes), and most request bodies are missing too: NSURLSession sends them as streams, so the event has only request.hasPostData: true."
),
clear: z
.boolean()
.optional()
.default(false)
.describe(
"Clear the log after reading. The cleared events are gone, so with format 'requests' their headers cannot be read later: read with format 'events' first if you need them."
),
});

type Params = z.infer<typeof zodSchema>;
type Result = NativeDevtoolsPrecheckBlock | { status: "ok"; count: number; events: NetworkEvent[] };
interface NativeRequestRow {
requestId: string;
method?: string;
url?: string;
finalUrl?: string;
status?: number;
mimeType?: string;
bytes?: number;
durationMs?: number;
error?: string;
}

type Result =
| NativeDevtoolsPrecheckBlock
| { status: "ok"; count: number; events: NetworkEvent[]; truncated?: true }
| {
status: "ok";
count: number;
totalRequests: number;
requests: NativeRequestRow[];
truncated?: true;
};

/**
* The inner NSURLSession reports the canonical URL (`https://x.com` comes back
* as `https://x.com/`, a host in lower case), so compare parsed forms: only a
* real redirect should produce a `finalUrl`.
*/
function sameUrl(a: string, b: string): boolean {
try {
return new URL(a).href === new URL(b).href;
} catch {
return a === b;
}
}

/** Fold CDP Network.* events into one row per requestId, in first-seen order. */
function summarizeRequests(events: NetworkEvent[]): NativeRequestRow[] {
const rows = new Map<string, NativeRequestRow & { start?: number }>();
for (const e of events) {
const p = (e.params ?? {}) as Record<string, any>;
const id = typeof p.requestId === "string" ? p.requestId : undefined;
if (!id) continue;
let row = rows.get(id);
if (!row) rows.set(id, (row = { requestId: id }));
const t = typeof p.timestamp === "number" ? p.timestamp : undefined;
const finish = () => {
if (typeof p.encodedDataLength === "number") row.bytes = p.encodedDataLength;
if (typeof p.nativeDevtoolsDurationMs === "number")
row.durationMs = Math.round(p.nativeDevtoolsDurationMs);
else if (t !== undefined && row.start !== undefined)
row.durationMs = Math.round((t - row.start) * 1000);
};
switch (e.method) {
case "Network.requestWillBeSent":
row.method = p.request?.method;
row.url = p.request?.url;
row.start = t;
break;
case "Network.responseReceived":
row.status = p.response?.status;
row.mimeType = p.response?.mimeType;
if (row.url && p.response?.url && !sameUrl(p.response.url, row.url))
row.finalUrl = p.response.url;
break;
case "Network.loadingFinished":
finish();
break;
case "Network.loadingFailed":
row.error = p.errorText ?? "failed";
Comment thread
blazejkustra marked this conversation as resolved.
Resolved
finish();
break;
}
}
return [...rows.values()].map(({ start: _start, ...row }) => row);
Comment thread
blazejkustra marked this conversation as resolved.
Resolved
}

export { summarizeRequests };

export const nativeNetworkLogsTool: ToolDefinition<Params, Result> = {
id: "native-network-logs",
Expand All @@ -36,7 +129,7 @@ export const nativeNetworkLogsTool: ToolDefinition<Params, Result> = {
description: `Retrieve network requests captured at the native NSURLProtocol level.
Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that never pass through JS XMLHttpRequest or fetch.
Use when you need to inspect native-level HTTP traffic that is invisible to the JS-level capture.
Returns { status, count, events } where each event contains URL, method, status code, headers, and timing.
Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error). finalUrl is set when a redirect ended elsewhere; status and mimeType are then those of finalUrl. A row with no bytes, durationMs or error has not finished: it is still running, or the app cancelled it. truncated: true means the log reached its 1000-event limit, so older requests are missing, totalRequests counts only what is left, and A row without method and url lost its first events, to that limit or to a clear: true read while it was in flight. Pass format: "events" for the raw CDP events with headers (most request bodies are not captured, and response bodies never are); it returns { status, count, events } plus the same truncated: true when the oldest events, usually the requestWillBeSent ones holding request headers, were dropped.
If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help. Restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If the same status comes back after that restart, stop restarting: follow the message, which names the terminal fallback. If status is connect_pending: the app is injected and still connecting. Do not restart it, wait a few seconds and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised. Follow the message (re-boot the simulator) rather than retrying this tool.
A not-running app comes back as one of those statuses rather than a failure. An app that is not connected does too, except on a device whose devtools agent argent attached to rather than armed itself: there it fails with \`NATIVE_DEVTOOLS_NOT_CONNECTED\`, since no restart of ours can complete a handshake we do not own. Failures are separate: an Apple system app is rejected outright (terminal: never retry it), while a missing host dependency or a udid that is not an Apple device is not.`,
zodSchema,
Expand Down Expand Up @@ -71,8 +164,24 @@ A not-running app comes back as one of those statuses rather than a failure. An

api.activateNetworkInspection(params.bundleId);

const events = api.getNetworkLog(params.bundleId).slice(-params.limit);
const log = api.getNetworkLog(params.bundleId);
const evicted = api.networkLogEvicted(params.bundleId);
if (params.clear) api.clearNetworkLog(params.bundleId);
Comment thread
blazejkustra marked this conversation as resolved.
Resolved
return { status: "ok", count: events.length, events };
// slice(-0) would return everything, so cut from the front instead.
const mostRecent = <T>(items: T[]) => items.slice(Math.max(0, items.length - params.limit));
const truncated = evicted ? { truncated: true as const } : {};
if (params.format === "events") {
const events = mostRecent(log);
return { status: "ok", count: events.length, events, ...truncated };
}
const all = summarizeRequests(log);
const requests = mostRecent(all);
return {
status: "ok",
count: requests.length,
totalRequests: all.length,
requests,
...truncated,
};
},
};
1 change: 1 addition & 0 deletions packages/tool-server/test/describe-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function makeNativeDevtoolsApi(options: {
activateNetworkInspection: () => {},
getNetworkLog: () => [],
clearNetworkLog: () => {},
networkLogEvicted: () => false,
getAppState: async (bundleId) => ({
bundleId,
applicationState: "active",
Expand Down
1 change: 1 addition & 0 deletions packages/tool-server/test/ios-launch-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function makeNativeApi(): NativeDevtoolsApi {
activateNetworkInspection: () => {},
getNetworkLog: () => [],
clearNetworkLog: () => {},
networkLogEvicted: () => false,
getAppState: async () => {
throw new Error("not implemented");
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function makeNativeApi(): NativeDevtoolsApi {
activateNetworkInspection: () => {},
getNetworkLog: () => [],
clearNetworkLog: () => {},
networkLogEvicted: () => false,
getAppState: async () => {
throw new Error("not implemented");
},
Expand Down
1 change: 1 addition & 0 deletions packages/tool-server/test/native-devtools-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ function makeNativeApi(options: {
activateNetworkInspection: () => {},
getNetworkLog: () => [],
clearNetworkLog: () => {},
networkLogEvicted: () => false,
getAppState: async () => {
throw new Error("not implemented");
},
Expand Down
100 changes: 100 additions & 0 deletions packages/tool-server/test/native-network-log-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, vi } from "vitest";
import * as net from "node:net";
import type { DeviceInfo } from "@argent/registry";

vi.mock("@argent/native-devtools-ios", () => ({
bootstrapDylibPath: () => "/fake/dylibs/libArgentInjectionBootstrap.dylib",
bootstrapDylibPathTcp: () => "/fake/dylibs/tcp/libArgentInjectionBootstrap.dylib",
bootstrapDylibPathTvos: () => "/fake/dylibs/tvos/libArgentInjectionBootstrap.dylib",
tcpInjectionDylibs: () => [],
axServiceBinaryPath: () => "/fake/ax-service",
axServiceBinaryPathTcp: () => "/fake/ax-service-tcp",
}));

type ExecCb = (err: Error | null, out: { stdout: string; stderr: string }) => void;

vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: (_cmd: string, _args: readonly string[], opts: unknown, cb?: ExecCb) => {
const callback = (typeof opts === "function" ? opts : cb!) as ExecCb;
callback(null, { stdout: "", stderr: "" });
},
};
});

import { nativeDevtoolsBlueprint, type NativeDevtoolsApi } from "../src/blueprints/native-devtools";
import { processScopedUdid } from "./helpers/process-scoped-udid";

const UDID = processScopedUdid("-5555-6666-7777-888888888888");
const BUNDLE = "com.example.app";
const LOG_LIMIT = 1000;

const device: DeviceInfo = { id: UDID, platform: "ios", kind: "simulator" };

function frame(type: string, payload: unknown): string {
return JSON.stringify({ type, payload }) + "\n";
}

function cdpEvent(index: number): string {
return frame("CDP", {
method: "Network.dataReceived",
params: { requestId: `r${index}`, dataLength: 1 },
});
}

function sendEvents(sock: net.Socket, from: number, to: number) {
const lines: string[] = [];
for (let i = from; i < to; i++) lines.push(cdpEvent(i));
sock.write(lines.join(""));
}

function requestIds(api: NativeDevtoolsApi): string[] {
return api.getNetworkLog(BUNDLE).map((e) => (e.params as { requestId: string }).requestId);
}

async function waitForLastEvent(api: NativeDevtoolsApi, index: number) {
await vi.waitFor(() => expect(requestIds(api).at(-1)).toBe(`r${index}`));
}

function connectAgent(socketPath: string): Promise<net.Socket> {
return new Promise((resolve) => {
const sock = net.createConnection(socketPath, () => {
sock.write(frame("Control", { bundleId: BUNDLE }));
resolve(sock);
});
});
}

describe.skipIf(process.platform === "win32")("native-devtools network log ring buffer", () => {
it("drops the oldest event past the limit, flags it, and resets on clear", async () => {
const instance = await nativeDevtoolsBlueprint.factory({}, device, { device });
const api = instance.api as NativeDevtoolsApi;
const sock = await connectAgent(api.socketPath);
try {
await vi.waitFor(() => expect(api.isConnected(BUNDLE)).toBe(true));

sendEvents(sock, 0, LOG_LIMIT);
await waitForLastEvent(api, LOG_LIMIT - 1);
expect(api.getNetworkLog(BUNDLE)).toHaveLength(LOG_LIMIT);
expect(api.networkLogEvicted(BUNDLE)).toBe(false);

sendEvents(sock, LOG_LIMIT, LOG_LIMIT + 1);
await waitForLastEvent(api, LOG_LIMIT);
const ids = requestIds(api);
expect(ids).toHaveLength(LOG_LIMIT);
expect(ids[0]).toBe("r1");
expect(api.networkLogEvicted(BUNDLE)).toBe(true);

const snapshot = api.getNetworkLog(BUNDLE);
api.clearNetworkLog(BUNDLE);
expect(snapshot).toHaveLength(LOG_LIMIT);
expect(api.getNetworkLog(BUNDLE)).toEqual([]);
expect(api.networkLogEvicted(BUNDLE)).toBe(false);
} finally {
sock.destroy();
await instance.dispose();
}
});
});
Loading
Loading