Skip to content

Commit 4bd4d97

Browse files
committed
feat: rewrite Rozenite plugin to use HTTP API and add playground integration
The CDP binding approach (Runtime.addBinding + Runtime.bindingCalled) does not work in Hermes/Fusebox New Architecture — binding events are routed to the DevTools frontend only, not to debugging sessions. Switch to @rozenite/metro's HTTP agent API (/rozenite/agent/*) which is designed for this use case. Remove bootstrap.ts and tool-registry.ts; the HTTP session owns tool state. CDP session disconnects no longer tear down the Rozenite session since the HTTP session is independent of the CDP transport. Add playground integration: metro.config.js wraps Expo config with withRozenite, use-rozenite-bridge.ts registers three test tools (echo, getTimestamp, getPlaygroundInfo), and index.tsx renders a Rozenite status section when the Fusebox dispatcher global is present.
1 parent f91de9f commit 4bd4d97

10 files changed

Lines changed: 783 additions & 470 deletions

File tree

packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts

Lines changed: 212 additions & 255 deletions
Large diffs are not rendered by default.

packages/agent-cdp/src/plugins/rozenite/bootstrap.ts

Lines changed: 0 additions & 53 deletions
This file was deleted.

packages/agent-cdp/src/plugins/rozenite/index.ts

Lines changed: 87 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import crypto from "node:crypto";
2-
31
import type { TargetDescriptor } from "@agent-cdp/protocol";
42

53
import type {
@@ -8,31 +6,24 @@ import type {
86
AgentPluginDetachContext,
97
AgentPluginState,
108
AgentPluginTargetContext,
11-
AgentPluginTargetSession,
129
} from "../../plugin.js";
13-
import { bootstrapRozenite } from "./bootstrap.js";
1410
import {
15-
DOMAIN_NAME,
16-
RUNTIME_GLOBAL,
17-
type AgentToAppMessage,
18-
type AppToAgentMessage,
19-
type BindingPayload,
11+
ROZENITE_AGENT_BASE,
12+
type RozeniteApiResponse,
13+
type RozeniteApiTool,
14+
type RozeniteSessionInfo,
2015
} from "./protocol.js";
21-
import { RozeniteToolRegistry } from "./tool-registry.js";
2216

2317
export class RozenitePlugin implements AgentPlugin {
2418
readonly id = "rozenite";
2519
readonly displayName = "Rozenite";
26-
readonly description = "Rozenite React Native devtools bridge";
20+
readonly description = "Rozenite React Native agent bridge";
2721
readonly commands: readonly AgentPluginCommand[];
2822

2923
private state: AgentPluginState = { kind: "idle" };
30-
private readonly registry = new RozeniteToolRegistry();
24+
private sessionId: string | null = null;
25+
private metroBaseUrl: string | null = null;
3126
private abortController: AbortController | null = null;
32-
private readonly pendingCalls = new Map<string, {
33-
resolve: (value: unknown) => void;
34-
reject: (reason: Error) => void;
35-
}>();
3627

3728
constructor() {
3829
this.commands = this.buildCommands();
@@ -47,90 +38,73 @@ export class RozenitePlugin implements AgentPlugin {
4738
}
4839

4940
async onTargetSelected(ctx: AgentPluginTargetContext): Promise<void> {
50-
this.state = { kind: "waiting-for-runtime", reason: `Waiting for ${RUNTIME_GLOBAL}` };
51-
this.registry.clear();
41+
this.state = { kind: "waiting-for-runtime", reason: "Connecting to Rozenite HTTP agent..." };
42+
this.sessionId = null;
43+
this.metroBaseUrl = null;
5244
this.abortController = new AbortController();
53-
ctx.session.onDisconnected(() => this.handleDisconnect());
54-
void this.runBootstrap(ctx.session);
45+
void this.connect(ctx.session.target);
5546
}
5647

5748
async onTargetReconnected(ctx: AgentPluginTargetContext): Promise<void> {
5849
return this.onTargetSelected(ctx);
5950
}
6051

6152
async onTargetCleared(_ctx: AgentPluginDetachContext): Promise<void> {
62-
this.teardown(new Error("Target cleared"));
53+
await this.teardown();
6354
this.state = { kind: "idle" };
6455
}
6556

66-
private handleDisconnect(): void {
67-
this.teardown(new Error("Target disconnected"));
68-
this.state = { kind: "idle" };
69-
}
70-
71-
private teardown(error: Error): void {
72-
this.abortController?.abort();
57+
private async teardown(): Promise<void> {
58+
const ctrl = this.abortController;
7359
this.abortController = null;
74-
this.registry.clear();
75-
for (const pending of this.pendingCalls.values()) {
76-
pending.reject(error);
60+
ctrl?.abort();
61+
62+
const { sessionId, metroBaseUrl } = this;
63+
this.sessionId = null;
64+
this.metroBaseUrl = null;
65+
66+
if (sessionId && metroBaseUrl) {
67+
void fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}`, {
68+
method: "DELETE",
69+
}).catch(() => {});
7770
}
78-
this.pendingCalls.clear();
7971
}
8072

81-
private async runBootstrap(session: AgentPluginTargetSession): Promise<void> {
73+
private async connect(target: TargetDescriptor): Promise<void> {
74+
const metroBaseUrl = target.sourceUrl;
75+
const deviceId = target.reactNative?.logicalDeviceId;
76+
const signal = this.abortController?.signal;
77+
8278
try {
83-
const { bindingName } = await bootstrapRozenite(session, this.abortController!.signal);
84-
85-
session.onEvent((event) => {
86-
if (event.method !== "Runtime.bindingCalled") return;
87-
const params = event.params as { name?: string; payload?: string };
88-
if (params.name !== bindingName) return;
89-
try {
90-
const envelope = JSON.parse(params.payload ?? "") as BindingPayload;
91-
if (envelope.domain !== DOMAIN_NAME) return;
92-
this.handleMessage(envelope.message as AppToAgentMessage);
93-
} catch {}
79+
const body: Record<string, string> = {};
80+
if (deviceId) body.deviceId = deviceId;
81+
82+
const response = await fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions`, {
83+
method: "POST",
84+
headers: { "Content-Type": "application/json" },
85+
body: JSON.stringify(body),
86+
signal,
9487
});
9588

96-
await this.sendToApp(session, { type: "agent-session-ready" });
97-
this.state = { kind: "ready" };
98-
} catch (err) {
99-
if ((err as Error).message !== "aborted") {
100-
this.state = { kind: "error", reason: (err as Error).message };
89+
const json = (await response.json()) as RozeniteApiResponse<{ session: RozeniteSessionInfo }>;
90+
91+
if (signal?.aborted) return;
92+
93+
if (!json.ok) {
94+
throw new Error(json.error?.message ?? "Failed to create Rozenite session");
10195
}
102-
}
103-
}
10496

105-
private handleMessage(msg: AppToAgentMessage): void {
106-
switch (msg.type) {
107-
case "register-tool":
108-
this.registry.register("app", msg.tools);
109-
break;
110-
case "unregister-tool":
111-
this.registry.unregister(msg.toolNames);
112-
break;
113-
case "tool-result": {
114-
const pending = this.pendingCalls.get(msg.callId);
115-
if (!pending) return;
116-
this.pendingCalls.delete(msg.callId);
117-
if (msg.success) {
118-
pending.resolve({ success: true, result: msg.result });
119-
} else {
120-
pending.resolve({ success: false, error: msg.error });
121-
}
122-
break;
97+
this.metroBaseUrl = metroBaseUrl;
98+
this.sessionId = json.result!.session.id;
99+
this.state = { kind: "ready" };
100+
} catch (err) {
101+
const error = err as Error;
102+
if (error.name !== "AbortError" && !signal?.aborted) {
103+
this.state = { kind: "error", reason: error.message };
123104
}
124105
}
125106
}
126107

127-
private async sendToApp(session: AgentPluginTargetSession, message: AgentToAppMessage): Promise<void> {
128-
const payload = JSON.stringify(JSON.stringify(message));
129-
await session.send("Runtime.evaluate", {
130-
expression: `${RUNTIME_GLOBAL}.sendMessage('${DOMAIN_NAME}', ${payload})`,
131-
});
132-
}
133-
134108
private buildCommands(): AgentPluginCommand[] {
135109
return [
136110
{
@@ -139,10 +113,20 @@ export class RozenitePlugin implements AgentPlugin {
139113
alwaysExecutable: true,
140114
execute: async (ctx) => {
141115
const state = ctx.getState();
116+
let toolCount = 0;
117+
if (state.kind === "ready" && this.sessionId && this.metroBaseUrl) {
118+
try {
119+
const resp = await fetch(
120+
`${this.metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${this.sessionId}`
121+
);
122+
const json = (await resp.json()) as RozeniteApiResponse<{ session: RozeniteSessionInfo }>;
123+
if (json.ok && json.result) toolCount = json.result.session.toolCount;
124+
} catch {}
125+
}
142126
return {
143127
state: state.kind,
144128
...(state.kind === "error" ? { error: state.reason } : {}),
145-
toolCount: this.registry.size,
129+
toolCount,
146130
target: ctx.session?.target ?? null,
147131
};
148132
},
@@ -151,51 +135,47 @@ export class RozenitePlugin implements AgentPlugin {
151135
name: "tools",
152136
summary: "List registered Rozenite tools",
153137
execute: async () => {
154-
return this.registry.list().map((t) => ({
155-
name: t.qualifiedName,
156-
description: t.description,
157-
}));
138+
const { sessionId, metroBaseUrl } = this;
139+
if (!sessionId || !metroBaseUrl) throw new Error("No active Rozenite session");
140+
const resp = await fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}/tools`);
141+
const json = (await resp.json()) as RozeniteApiResponse<{ tools: RozeniteApiTool[] }>;
142+
if (!json.ok) throw new Error(json.error?.message ?? "Failed to list tools");
143+
return (json.result?.tools ?? []).map((t) => ({ name: t.name, description: t.description }));
158144
},
159145
},
160146
{
161147
name: "tool-schema",
162148
summary: "Show input schema for a Rozenite tool",
163149
execute: async (_ctx, input) => {
164150
const { name } = input as { name: string };
165-
const tool = this.registry.get(name);
151+
const { sessionId, metroBaseUrl } = this;
152+
if (!sessionId || !metroBaseUrl) throw new Error("No active Rozenite session");
153+
const resp = await fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}/tools`);
154+
const json = (await resp.json()) as RozeniteApiResponse<{ tools: RozeniteApiTool[] }>;
155+
if (!json.ok) throw new Error(json.error?.message ?? "Failed to fetch tools");
156+
const tool = (json.result?.tools ?? []).find((t) => t.name === name);
166157
if (!tool) throw new Error(`Tool '${name}' not found`);
167158
return tool.inputSchema;
168159
},
169160
},
170161
{
171162
name: "call",
172163
summary: "Call a Rozenite tool",
173-
execute: async (ctx, input) => {
164+
execute: async (_ctx, input) => {
174165
const { name, arguments: args } = input as { name: string; arguments?: unknown };
175-
const tool = this.registry.get(name);
176-
if (!tool) throw new Error(`Tool '${name}' not found`);
177-
178-
const callId = crypto.randomUUID();
179-
return new Promise<unknown>((resolve, reject) => {
180-
this.pendingCalls.set(callId, { resolve, reject });
181-
182-
void this.sendToApp(ctx.session!, {
183-
type: "tool-call",
184-
callId,
185-
toolName: name,
186-
arguments: args ?? null,
187-
}).catch((err: unknown) => {
188-
this.pendingCalls.delete(callId);
189-
reject(err instanceof Error ? err : new Error(String(err)));
190-
});
191-
192-
setTimeout(() => {
193-
if (this.pendingCalls.has(callId)) {
194-
this.pendingCalls.delete(callId);
195-
reject(new Error(`Tool call '${name}' timed out after 60s`));
196-
}
197-
}, 60_000);
198-
});
166+
const { sessionId, metroBaseUrl } = this;
167+
if (!sessionId || !metroBaseUrl) throw new Error("No active Rozenite session");
168+
const resp = await fetch(
169+
`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}/call-tool`,
170+
{
171+
method: "POST",
172+
headers: { "Content-Type": "application/json" },
173+
body: JSON.stringify({ toolName: name, args: args ?? null }),
174+
}
175+
);
176+
const json = (await resp.json()) as RozeniteApiResponse<{ result: unknown }>;
177+
if (!json.ok) throw new Error(json.error?.message ?? "Tool call failed");
178+
return json.result?.result;
199179
},
200180
},
201181
];
Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
1-
export const RUNTIME_GLOBAL = "__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__";
2-
export const DOMAIN_NAME = "rozenite";
3-
export const POLL_INTERVAL_MS = 500;
4-
export const POLL_TIMEOUT_MS = 30_000;
1+
export const ROZENITE_AGENT_BASE = "/rozenite/agent";
52

6-
export interface AgentTool {
3+
export interface RozeniteApiTool {
74
name: string;
85
description: string;
96
inputSchema: object;
107
}
118

12-
export type AppToAgentMessage =
13-
| { type: "register-tool"; tools: AgentTool[] }
14-
| { type: "unregister-tool"; toolNames: string[] }
15-
| { type: "tool-result"; callId: string; success: true; result: unknown }
16-
| { type: "tool-result"; callId: string; success: false; error: string };
17-
18-
export type AgentToAppMessage =
19-
| { type: "agent-session-ready" }
20-
| { type: "tool-call"; callId: string; toolName: string; arguments: unknown };
9+
export interface RozeniteSessionInfo {
10+
id: string;
11+
deviceId: string;
12+
deviceName: string;
13+
status: string;
14+
toolCount: number;
15+
createdAt: number;
16+
lastActivityAt: number;
17+
connectedAt?: number;
18+
lastError?: string;
19+
}
2120

22-
export interface BindingPayload {
23-
domain: string;
24-
message: unknown;
21+
export interface RozeniteApiResponse<T> {
22+
ok: boolean;
23+
result?: T;
24+
error?: { message: string };
2525
}

0 commit comments

Comments
 (0)