devlens-mcp 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/.claude/settings.json +12 -0
  2. package/.claude/settings.local.json +17 -0
  3. package/INSTALLATION_GUIDE.md +354 -0
  4. package/QUICK_START.md +153 -0
  5. package/README.md +354 -0
  6. package/bin/cli.ts +22 -0
  7. package/bin/register.ts +96 -0
  8. package/dist/bin/cli.d.ts +3 -0
  9. package/dist/bin/cli.d.ts.map +1 -0
  10. package/dist/bin/cli.js +20 -0
  11. package/dist/bin/cli.js.map +1 -0
  12. package/dist/bin/register.d.ts +10 -0
  13. package/dist/bin/register.d.ts.map +1 -0
  14. package/dist/bin/register.js +92 -0
  15. package/dist/bin/register.js.map +1 -0
  16. package/dist/src/config/devlens-config.d.ts +92 -0
  17. package/dist/src/config/devlens-config.d.ts.map +1 -0
  18. package/dist/src/config/devlens-config.js +70 -0
  19. package/dist/src/config/devlens-config.js.map +1 -0
  20. package/dist/src/index.d.ts +35 -0
  21. package/dist/src/index.d.ts.map +1 -0
  22. package/dist/src/index.js +8 -0
  23. package/dist/src/index.js.map +1 -0
  24. package/dist/src/metro/cdp-client.d.ts +48 -0
  25. package/dist/src/metro/cdp-client.d.ts.map +1 -0
  26. package/dist/src/metro/cdp-client.js +127 -0
  27. package/dist/src/metro/cdp-client.js.map +1 -0
  28. package/dist/src/metro/log-collector.d.ts +30 -0
  29. package/dist/src/metro/log-collector.d.ts.map +1 -0
  30. package/dist/src/metro/log-collector.js +114 -0
  31. package/dist/src/metro/log-collector.js.map +1 -0
  32. package/dist/src/metro/metro-bridge.d.ts +56 -0
  33. package/dist/src/metro/metro-bridge.d.ts.map +1 -0
  34. package/dist/src/metro/metro-bridge.js +255 -0
  35. package/dist/src/metro/metro-bridge.js.map +1 -0
  36. package/dist/src/metro/network-inspector.d.ts +34 -0
  37. package/dist/src/metro/network-inspector.d.ts.map +1 -0
  38. package/dist/src/metro/network-inspector.js +100 -0
  39. package/dist/src/metro/network-inspector.js.map +1 -0
  40. package/dist/src/platform/android/adb.d.ts +50 -0
  41. package/dist/src/platform/android/adb.d.ts.map +1 -0
  42. package/dist/src/platform/android/adb.js +137 -0
  43. package/dist/src/platform/android/adb.js.map +1 -0
  44. package/dist/src/platform/android/android-device.d.ts +21 -0
  45. package/dist/src/platform/android/android-device.d.ts.map +1 -0
  46. package/dist/src/platform/android/android-device.js +94 -0
  47. package/dist/src/platform/android/android-device.js.map +1 -0
  48. package/dist/src/platform/android/ui-automator.d.ts +17 -0
  49. package/dist/src/platform/android/ui-automator.d.ts.map +1 -0
  50. package/dist/src/platform/android/ui-automator.js +126 -0
  51. package/dist/src/platform/android/ui-automator.js.map +1 -0
  52. package/dist/src/platform/device-manager.d.ts +28 -0
  53. package/dist/src/platform/device-manager.d.ts.map +1 -0
  54. package/dist/src/platform/device-manager.js +185 -0
  55. package/dist/src/platform/device-manager.js.map +1 -0
  56. package/dist/src/platform/device.d.ts +86 -0
  57. package/dist/src/platform/device.d.ts.map +1 -0
  58. package/dist/src/platform/device.js +7 -0
  59. package/dist/src/platform/device.js.map +1 -0
  60. package/dist/src/platform/ios/accessibility.d.ts +17 -0
  61. package/dist/src/platform/ios/accessibility.d.ts.map +1 -0
  62. package/dist/src/platform/ios/accessibility.js +159 -0
  63. package/dist/src/platform/ios/accessibility.js.map +1 -0
  64. package/dist/src/platform/ios/ios-device.d.ts +22 -0
  65. package/dist/src/platform/ios/ios-device.d.ts.map +1 -0
  66. package/dist/src/platform/ios/ios-device.js +97 -0
  67. package/dist/src/platform/ios/ios-device.js.map +1 -0
  68. package/dist/src/platform/ios/simctl.d.ts +54 -0
  69. package/dist/src/platform/ios/simctl.d.ts.map +1 -0
  70. package/dist/src/platform/ios/simctl.js +192 -0
  71. package/dist/src/platform/ios/simctl.js.map +1 -0
  72. package/dist/src/server.d.ts +3 -0
  73. package/dist/src/server.d.ts.map +1 -0
  74. package/dist/src/server.js +176 -0
  75. package/dist/src/server.js.map +1 -0
  76. package/dist/src/snapshot/formatter.d.ts +18 -0
  77. package/dist/src/snapshot/formatter.d.ts.map +1 -0
  78. package/dist/src/snapshot/formatter.js +86 -0
  79. package/dist/src/snapshot/formatter.js.map +1 -0
  80. package/dist/src/snapshot/ref-registry.d.ts +67 -0
  81. package/dist/src/snapshot/ref-registry.d.ts.map +1 -0
  82. package/dist/src/snapshot/ref-registry.js +169 -0
  83. package/dist/src/snapshot/ref-registry.js.map +1 -0
  84. package/dist/src/snapshot/snapshot-differ.d.ts +57 -0
  85. package/dist/src/snapshot/snapshot-differ.d.ts.map +1 -0
  86. package/dist/src/snapshot/snapshot-differ.js +153 -0
  87. package/dist/src/snapshot/snapshot-differ.js.map +1 -0
  88. package/dist/src/tools/app-tools.d.ts +71 -0
  89. package/dist/src/tools/app-tools.d.ts.map +1 -0
  90. package/dist/src/tools/app-tools.js +97 -0
  91. package/dist/src/tools/app-tools.js.map +1 -0
  92. package/dist/src/tools/device-tools.d.ts +53 -0
  93. package/dist/src/tools/device-tools.d.ts.map +1 -0
  94. package/dist/src/tools/device-tools.js +86 -0
  95. package/dist/src/tools/device-tools.js.map +1 -0
  96. package/dist/src/tools/ds-tools.d.ts +65 -0
  97. package/dist/src/tools/ds-tools.d.ts.map +1 -0
  98. package/dist/src/tools/ds-tools.js +314 -0
  99. package/dist/src/tools/ds-tools.js.map +1 -0
  100. package/dist/src/tools/interaction-tools.d.ts +248 -0
  101. package/dist/src/tools/interaction-tools.d.ts.map +1 -0
  102. package/dist/src/tools/interaction-tools.js +391 -0
  103. package/dist/src/tools/interaction-tools.js.map +1 -0
  104. package/dist/src/tools/metro-tools.d.ts +115 -0
  105. package/dist/src/tools/metro-tools.d.ts.map +1 -0
  106. package/dist/src/tools/metro-tools.js +270 -0
  107. package/dist/src/tools/metro-tools.js.map +1 -0
  108. package/dist/src/tools/navigation-tools.d.ts +36 -0
  109. package/dist/src/tools/navigation-tools.d.ts.map +1 -0
  110. package/dist/src/tools/navigation-tools.js +60 -0
  111. package/dist/src/tools/navigation-tools.js.map +1 -0
  112. package/dist/src/tools/screenshot-tools.d.ts +298 -0
  113. package/dist/src/tools/screenshot-tools.d.ts.map +1 -0
  114. package/dist/src/tools/screenshot-tools.js +565 -0
  115. package/dist/src/tools/screenshot-tools.js.map +1 -0
  116. package/dist/src/tools/snapshot-tools.d.ts +161 -0
  117. package/dist/src/tools/snapshot-tools.d.ts.map +1 -0
  118. package/dist/src/tools/snapshot-tools.js +479 -0
  119. package/dist/src/tools/snapshot-tools.js.map +1 -0
  120. package/dist/src/utils/image-preprocess.d.ts +49 -0
  121. package/dist/src/utils/image-preprocess.d.ts.map +1 -0
  122. package/dist/src/utils/image-preprocess.js +322 -0
  123. package/dist/src/utils/image-preprocess.js.map +1 -0
  124. package/dist/src/utils/retry.d.ts +21 -0
  125. package/dist/src/utils/retry.d.ts.map +1 -0
  126. package/dist/src/utils/retry.js +33 -0
  127. package/dist/src/utils/retry.js.map +1 -0
  128. package/dist/src/visual/comparator.d.ts +51 -0
  129. package/dist/src/visual/comparator.d.ts.map +1 -0
  130. package/dist/src/visual/comparator.js +119 -0
  131. package/dist/src/visual/comparator.js.map +1 -0
  132. package/dist/src/visual/layout-analyzer.d.ts +64 -0
  133. package/dist/src/visual/layout-analyzer.d.ts.map +1 -0
  134. package/dist/src/visual/layout-analyzer.js +198 -0
  135. package/dist/src/visual/layout-analyzer.js.map +1 -0
  136. package/dist/src/visual/screenshot.d.ts +17 -0
  137. package/dist/src/visual/screenshot.d.ts.map +1 -0
  138. package/dist/src/visual/screenshot.js +39 -0
  139. package/dist/src/visual/screenshot.js.map +1 -0
  140. package/docs/figma-workflow.md +289 -0
  141. package/docs/setup-guide.md +360 -0
  142. package/docs/tool-reference.md +622 -0
  143. package/package.json +57 -0
  144. package/src/config/devlens-config.ts +76 -0
  145. package/src/index.ts +5 -0
  146. package/src/metro/cdp-client.ts +160 -0
  147. package/src/metro/log-collector.ts +137 -0
  148. package/src/metro/metro-bridge.ts +307 -0
  149. package/src/metro/network-inspector.ts +134 -0
  150. package/src/platform/android/adb.ts +200 -0
  151. package/src/platform/android/android-device.ts +116 -0
  152. package/src/platform/android/ui-automator.ts +141 -0
  153. package/src/platform/device-manager.ts +229 -0
  154. package/src/platform/device.ts +110 -0
  155. package/src/platform/ios/accessibility.ts +189 -0
  156. package/src/platform/ios/ios-device.ts +116 -0
  157. package/src/platform/ios/simctl.ts +244 -0
  158. package/src/server.ts +228 -0
  159. package/src/snapshot/formatter.ts +102 -0
  160. package/src/snapshot/ref-registry.ts +230 -0
  161. package/src/snapshot/snapshot-differ.ts +220 -0
  162. package/src/tools/app-tools.ts +111 -0
  163. package/src/tools/device-tools.ts +96 -0
  164. package/src/tools/ds-tools.ts +395 -0
  165. package/src/tools/interaction-tools.ts +467 -0
  166. package/src/tools/metro-tools.ts +320 -0
  167. package/src/tools/navigation-tools.ts +71 -0
  168. package/src/tools/screenshot-tools.ts +698 -0
  169. package/src/tools/snapshot-tools.ts +585 -0
  170. package/src/utils/image-preprocess.ts +430 -0
  171. package/src/utils/retry.ts +51 -0
  172. package/src/visual/comparator.ts +191 -0
  173. package/src/visual/layout-analyzer.ts +283 -0
  174. package/src/visual/screenshot.ts +49 -0
  175. package/tsconfig.json +20 -0
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { readFile } from "fs/promises";
3
+ import { resolve } from "path";
4
+
5
+ /**
6
+ * DevLens configuration file support.
7
+ *
8
+ * Loaded from (in priority order):
9
+ * 1. Path pointed to by DEVLENS_CONFIG env var
10
+ * 2. ./devlens.config.json in CWD
11
+ *
12
+ * Place devlens.config.json in your consumer app root and point to it
13
+ * via the MCP env block:
14
+ *
15
+ * "env": {
16
+ * "METRO_PORT": "8081",
17
+ * "FIGMA_TOKEN": "figd_xxx",
18
+ * "DEVLENS_CONFIG": "/path/to/your-app/devlens.config.json"
19
+ * }
20
+ */
21
+
22
+ export const DesignSystemConfigSchema = z.object({
23
+ /** Design system identifier, e.g. "jds3" */
24
+ name: z.string(),
25
+ /** Absolute or CWD-relative path to the consumer app root */
26
+ projectRoot: z.string(),
27
+ /** Relative path from projectRoot to the tokens file, e.g. "src/constants/figmaTokens.ts" */
28
+ tokensFile: z.string(),
29
+ /** Relative path from projectRoot to the generated component interface directory */
30
+ componentsDir: z.string().optional(),
31
+ /** Glob pattern for component source files (default: "src/**\/*.tsx") */
32
+ componentsGlob: z.string().default("src/**/*.tsx"),
33
+ });
34
+
35
+ export const DevLensConfigSchema = z.object({
36
+ designSystem: DesignSystemConfigSchema.optional(),
37
+ });
38
+
39
+ export type DevLensConfig = z.infer<typeof DevLensConfigSchema>;
40
+ export type DesignSystemConfig = z.infer<typeof DesignSystemConfigSchema>;
41
+
42
+ /**
43
+ * Load DevLens config from DEVLENS_CONFIG env var path, or ./devlens.config.json in CWD.
44
+ * Returns empty config {} if neither is found or both are invalid.
45
+ * Never throws — invalid config is logged and skipped.
46
+ */
47
+ export async function loadDevLensConfig(): Promise<DevLensConfig> {
48
+ const candidates: string[] = [];
49
+
50
+ const envPath = process.env.DEVLENS_CONFIG;
51
+ if (envPath) {
52
+ candidates.push(resolve(envPath));
53
+ }
54
+ candidates.push(resolve(process.cwd(), "devlens.config.json"));
55
+
56
+ for (const candidate of candidates) {
57
+ try {
58
+ const raw = await readFile(candidate, "utf-8");
59
+ const parsed = JSON.parse(raw);
60
+ const result = DevLensConfigSchema.safeParse(parsed);
61
+ if (result.success) {
62
+ console.error(`[devlens] Config loaded from: ${candidate}`);
63
+ return result.data;
64
+ } else {
65
+ console.error(
66
+ `[devlens] Config at ${candidate} is invalid:`,
67
+ JSON.stringify(result.error.format(), null, 2)
68
+ );
69
+ }
70
+ } catch {
71
+ // File not found or JSON parse error — try next candidate
72
+ }
73
+ }
74
+
75
+ return {};
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { startServer } from "./server.js";
2
+
3
+ export async function createServer() {
4
+ return startServer();
5
+ }
@@ -0,0 +1,160 @@
1
+ import WebSocket from "ws";
2
+
3
+ /**
4
+ * Chrome DevTools Protocol (CDP) client for communicating with the
5
+ * Metro/Hermes debugger. Connects via WebSocket and sends JSON-RPC messages.
6
+ *
7
+ * Metro exposes the debugger endpoint at:
8
+ * ws://localhost:{METRO_PORT}/inspector/device?device=0&page=-1
9
+ *
10
+ * Through CDP we can:
11
+ * - Get console logs (Console.enable → Console.messageAdded)
12
+ * - Intercept network requests (Network.enable → Network.requestWillBeSent)
13
+ * - Execute JavaScript in the RN context (Runtime.evaluate)
14
+ * - Trigger hot reload (via Runtime.evaluate)
15
+ */
16
+
17
+ export interface CdpMessage {
18
+ id?: number;
19
+ method?: string;
20
+ params?: any;
21
+ result?: any;
22
+ error?: { message: string; code?: number };
23
+ }
24
+
25
+ type MessageHandler = (message: CdpMessage) => void;
26
+
27
+ export class CdpClient {
28
+ private ws: WebSocket | null = null;
29
+ private messageId: number = 0;
30
+ private pendingRequests: Map<
31
+ number,
32
+ { resolve: (value: any) => void; reject: (error: Error) => void }
33
+ > = new Map();
34
+ private eventHandlers: Map<string, MessageHandler[]> = new Map();
35
+ private connected: boolean = false;
36
+
37
+ constructor(private endpoint: string) {}
38
+
39
+ /** Connect to the CDP endpoint */
40
+ async connect(): Promise<void> {
41
+ return new Promise((resolve, reject) => {
42
+ this.ws = new WebSocket(this.endpoint);
43
+
44
+ const timeout = setTimeout(() => {
45
+ reject(new Error(`CDP connection timeout to ${this.endpoint}`));
46
+ }, 5000);
47
+
48
+ this.ws.on("open", () => {
49
+ clearTimeout(timeout);
50
+ this.connected = true;
51
+ resolve();
52
+ });
53
+
54
+ this.ws.on("message", (data: WebSocket.Data) => {
55
+ try {
56
+ const message: CdpMessage = JSON.parse(data.toString());
57
+ this.handleMessage(message);
58
+ } catch {
59
+ // Ignore malformed messages
60
+ }
61
+ });
62
+
63
+ this.ws.on("close", () => {
64
+ this.connected = false;
65
+ // Reject all pending requests
66
+ for (const [, { reject }] of this.pendingRequests) {
67
+ reject(new Error("CDP connection closed"));
68
+ }
69
+ this.pendingRequests.clear();
70
+ });
71
+
72
+ this.ws.on("error", (err) => {
73
+ clearTimeout(timeout);
74
+ if (!this.connected) {
75
+ reject(err);
76
+ }
77
+ });
78
+ });
79
+ }
80
+
81
+ /** Send a CDP command and wait for the response */
82
+ async send(method: string, params?: any): Promise<any> {
83
+ if (!this.ws || !this.connected) {
84
+ throw new Error("CDP client not connected");
85
+ }
86
+
87
+ const id = ++this.messageId;
88
+ const message = JSON.stringify({ id, method, params });
89
+
90
+ return new Promise((resolve, reject) => {
91
+ const timeout = setTimeout(() => {
92
+ this.pendingRequests.delete(id);
93
+ reject(new Error(`CDP request timeout: ${method}`));
94
+ }, 10000);
95
+
96
+ this.pendingRequests.set(id, {
97
+ resolve: (result) => {
98
+ clearTimeout(timeout);
99
+ resolve(result);
100
+ },
101
+ reject: (error) => {
102
+ clearTimeout(timeout);
103
+ reject(error);
104
+ },
105
+ });
106
+
107
+ this.ws!.send(message);
108
+ });
109
+ }
110
+
111
+ /** Register an event handler for CDP events */
112
+ on(method: string, handler: MessageHandler): void {
113
+ const handlers = this.eventHandlers.get(method) || [];
114
+ handlers.push(handler);
115
+ this.eventHandlers.set(method, handlers);
116
+ }
117
+
118
+ /** Remove event handlers for a method */
119
+ off(method: string): void {
120
+ this.eventHandlers.delete(method);
121
+ }
122
+
123
+ /** Check if connected */
124
+ isConnected(): boolean {
125
+ return this.connected;
126
+ }
127
+
128
+ /** Close the connection */
129
+ close(): void {
130
+ if (this.ws) {
131
+ this.ws.close();
132
+ this.ws = null;
133
+ this.connected = false;
134
+ }
135
+ }
136
+
137
+ private handleMessage(message: CdpMessage): void {
138
+ // Response to a request
139
+ if (message.id !== undefined) {
140
+ const pending = this.pendingRequests.get(message.id);
141
+ if (pending) {
142
+ this.pendingRequests.delete(message.id);
143
+ if (message.error) {
144
+ pending.reject(new Error(message.error.message));
145
+ } else {
146
+ pending.resolve(message.result);
147
+ }
148
+ }
149
+ return;
150
+ }
151
+
152
+ // Event notification
153
+ if (message.method) {
154
+ const handlers = this.eventHandlers.get(message.method) || [];
155
+ for (const handler of handlers) {
156
+ handler(message);
157
+ }
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,137 @@
1
+ import type { CdpClient, CdpMessage } from "./cdp-client.js";
2
+
3
+ /**
4
+ * Collects console.log/warn/error messages from the React Native app
5
+ * via the CDP Console and Runtime domains.
6
+ */
7
+
8
+ export interface LogEntry {
9
+ level: "log" | "info" | "warn" | "error" | "debug";
10
+ message: string;
11
+ timestamp: number;
12
+ source?: string;
13
+ }
14
+
15
+ export class LogCollector {
16
+ private logs: LogEntry[] = [];
17
+ private maxLogs: number = 500;
18
+
19
+ constructor(private cdp: CdpClient) {}
20
+
21
+ /** Start listening for console messages */
22
+ start(): void {
23
+ // Listen for Console.messageAdded (older CDP)
24
+ this.cdp.on("Console.messageAdded", (msg: CdpMessage) => {
25
+ const entry = msg.params?.message;
26
+ if (entry) {
27
+ this.addLog({
28
+ level: this.normalizeLevel(entry.level),
29
+ message: entry.text || "",
30
+ timestamp: Date.now(),
31
+ source: entry.source,
32
+ });
33
+ }
34
+ });
35
+
36
+ // Listen for Runtime.consoleAPICalled (newer CDP)
37
+ this.cdp.on("Runtime.consoleAPICalled", (msg: CdpMessage) => {
38
+ const params = msg.params;
39
+ if (params) {
40
+ const args = params.args || [];
41
+ const message = args
42
+ .map((arg: any) => {
43
+ if (arg.type === "string") return arg.value;
44
+ if (arg.type === "number") return String(arg.value);
45
+ if (arg.type === "boolean") return String(arg.value);
46
+ if (arg.type === "undefined") return "undefined";
47
+ if (arg.type === "object" && arg.value)
48
+ return JSON.stringify(arg.value);
49
+ if (arg.description) return arg.description;
50
+ return String(arg.value ?? "");
51
+ })
52
+ .join(" ");
53
+
54
+ this.addLog({
55
+ level: this.normalizeLevel(params.type),
56
+ message,
57
+ timestamp: params.timestamp || Date.now(),
58
+ });
59
+ }
60
+ });
61
+
62
+ // Listen for Runtime.exceptionThrown
63
+ this.cdp.on("Runtime.exceptionThrown", (msg: CdpMessage) => {
64
+ const details = msg.params?.exceptionDetails;
65
+ if (details) {
66
+ this.addLog({
67
+ level: "error",
68
+ message:
69
+ details.text ||
70
+ details.exception?.description ||
71
+ "Unknown exception",
72
+ timestamp: Date.now(),
73
+ source: "exception",
74
+ });
75
+ }
76
+ });
77
+ }
78
+
79
+ /** Get collected logs, optionally filtered by level and time */
80
+ getLogs(options?: {
81
+ level?: "log" | "info" | "warn" | "error" | "debug";
82
+ since?: number;
83
+ limit?: number;
84
+ }): LogEntry[] {
85
+ let filtered = this.logs;
86
+
87
+ if (options?.level) {
88
+ const levelOrder = { debug: 0, log: 1, info: 2, warn: 3, error: 4 };
89
+ const minLevel = levelOrder[options.level] ?? 0;
90
+ filtered = filtered.filter(
91
+ (log) => (levelOrder[log.level] ?? 0) >= minLevel
92
+ );
93
+ }
94
+
95
+ if (options?.since) {
96
+ filtered = filtered.filter((log) => log.timestamp >= options.since!);
97
+ }
98
+
99
+ if (options?.limit) {
100
+ filtered = filtered.slice(-options.limit);
101
+ }
102
+
103
+ return filtered;
104
+ }
105
+
106
+ /** Clear all collected logs */
107
+ clear(): void {
108
+ this.logs = [];
109
+ }
110
+
111
+ private addLog(entry: LogEntry): void {
112
+ this.logs.push(entry);
113
+ // Keep buffer bounded
114
+ if (this.logs.length > this.maxLogs) {
115
+ this.logs = this.logs.slice(-Math.floor(this.maxLogs * 0.8));
116
+ }
117
+ }
118
+
119
+ private normalizeLevel(
120
+ level: string
121
+ ): "log" | "info" | "warn" | "error" | "debug" {
122
+ switch (level?.toLowerCase()) {
123
+ case "warning":
124
+ case "warn":
125
+ return "warn";
126
+ case "error":
127
+ return "error";
128
+ case "debug":
129
+ case "verbose":
130
+ return "debug";
131
+ case "info":
132
+ return "info";
133
+ default:
134
+ return "log";
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,307 @@
1
+ import { CdpClient } from "./cdp-client.js";
2
+ import { LogCollector } from "./log-collector.js";
3
+ import { NetworkInspector } from "./network-inspector.js";
4
+
5
+ /**
6
+ * Metro Bridge — manages the connection to Metro bundler's debugger
7
+ * and provides access to console logs, network requests, and JS execution.
8
+ *
9
+ * Metro/Hermes exposes CDP endpoints. To discover available pages:
10
+ * GET http://localhost:{port}/json
11
+ *
12
+ * The main debugger endpoint is typically:
13
+ * ws://localhost:{port}/inspector/device?device=0&page=-1
14
+ *
15
+ * For newer React Native (0.76+) with the new debugger:
16
+ * ws://localhost:{port}/inspector/device?device=0&page=1
17
+ */
18
+
19
+ export interface MetroStatus {
20
+ /** Whether the Metro packager process is running and responding */
21
+ running: boolean;
22
+ /** The port being checked */
23
+ port: number;
24
+ /** Raw response from /status endpoint (e.g., "packager-status:running") */
25
+ packagerStatus: string | null;
26
+ /** Whether a CDP WebSocket connection is currently active */
27
+ cdpConnected: boolean;
28
+ /** Error message if Metro is unreachable */
29
+ error?: string;
30
+ }
31
+
32
+ export class MetroBridge {
33
+ private cdp: CdpClient | null = null;
34
+ private _logCollector: LogCollector | null = null;
35
+ private _networkInspector: NetworkInspector | null = null;
36
+ private _connected: boolean = false;
37
+
38
+ constructor(private port: number = 8081) {}
39
+
40
+ /** Connect to Metro bundler's debugger */
41
+ async connect(): Promise<void> {
42
+ if (this._connected) return;
43
+
44
+ // Try to discover the debugger endpoint
45
+ const endpoint = await this.discoverEndpoint();
46
+ this.cdp = new CdpClient(endpoint);
47
+ await this.cdp.connect();
48
+
49
+ // Initialize collectors
50
+ this._logCollector = new LogCollector(this.cdp);
51
+ this._networkInspector = new NetworkInspector(this.cdp);
52
+
53
+ // Enable CDP domains
54
+ await this.cdp.send("Runtime.enable");
55
+ await this.cdp.send("Console.enable").catch(() => {
56
+ // Console domain might not be available in all environments
57
+ });
58
+
59
+ // Start collecting
60
+ this._logCollector.start();
61
+ this._networkInspector.start();
62
+
63
+ this._connected = true;
64
+ }
65
+
66
+ /** Disconnect from Metro */
67
+ disconnect(): void {
68
+ if (this.cdp) {
69
+ this.cdp.close();
70
+ this.cdp = null;
71
+ }
72
+ this._logCollector = null;
73
+ this._networkInspector = null;
74
+ this._connected = false;
75
+ }
76
+
77
+ /** Check if connected to Metro */
78
+ get connected(): boolean {
79
+ return this._connected && (this.cdp?.isConnected() ?? false);
80
+ }
81
+
82
+ /** Get the log collector */
83
+ get logCollector(): LogCollector {
84
+ if (!this._logCollector) {
85
+ throw new Error("Not connected to Metro. Call connect() first.");
86
+ }
87
+ return this._logCollector;
88
+ }
89
+
90
+ /** Get the network inspector */
91
+ get networkInspector(): NetworkInspector {
92
+ if (!this._networkInspector) {
93
+ throw new Error("Not connected to Metro. Call connect() first.");
94
+ }
95
+ return this._networkInspector;
96
+ }
97
+
98
+ /** Execute JavaScript in the React Native context */
99
+ async evaluate(expression: string): Promise<any> {
100
+ if (!this.cdp || !this._connected) {
101
+ throw new Error("Not connected to Metro.");
102
+ }
103
+
104
+ const result = await this.cdp.send("Runtime.evaluate", {
105
+ expression,
106
+ returnByValue: true,
107
+ awaitPromise: true,
108
+ });
109
+
110
+ if (result?.exceptionDetails) {
111
+ throw new Error(
112
+ `JS Error: ${result.exceptionDetails.text || result.exceptionDetails.exception?.description}`
113
+ );
114
+ }
115
+
116
+ return result?.result?.value;
117
+ }
118
+
119
+ /** Trigger a hot reload / fast refresh */
120
+ async hotReload(): Promise<void> {
121
+ try {
122
+ // Method 1: Use the Metro reload endpoint
123
+ const http = await import("http");
124
+ await new Promise<void>((resolve, reject) => {
125
+ const req = http.request(
126
+ {
127
+ hostname: "localhost",
128
+ port: this.port,
129
+ path: "/reload",
130
+ method: "POST",
131
+ },
132
+ (res) => {
133
+ res.resume();
134
+ resolve();
135
+ }
136
+ );
137
+ req.on("error", reject);
138
+ req.setTimeout(5000, () => {
139
+ req.destroy();
140
+ reject(new Error("Reload request timeout"));
141
+ });
142
+ req.end();
143
+ });
144
+ } catch {
145
+ // Method 2: Fallback - evaluate reload command via CDP
146
+ if (this.cdp && this._connected) {
147
+ await this.cdp.send("Runtime.evaluate", {
148
+ expression:
149
+ 'if (typeof __DEV__ !== "undefined" && __DEV__) { const DevSettings = require("react-native/Libraries/Utilities/DevSettings"); DevSettings.reload(); }',
150
+ });
151
+ }
152
+ }
153
+ }
154
+
155
+ /** Get the React component tree by evaluating JS in the RN context */
156
+ async getComponentTree(maxDepth: number = 10): Promise<string> {
157
+ if (!this.cdp || !this._connected) {
158
+ throw new Error("Not connected to Metro.");
159
+ }
160
+
161
+ // Inject a helper that traverses the React fiber tree
162
+ const script = `
163
+ (function() {
164
+ try {
165
+ // Find the root fiber
166
+ const roots = [];
167
+ const fiberRoots = document?.querySelectorAll?.('[data-reactroot]') || [];
168
+
169
+ // For React Native, we need to find the fiber root differently
170
+ // The __REACT_DEVTOOLS_GLOBAL_HOOK__ is available when DevTools is enabled
171
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
172
+ if (!hook || !hook.renderers) {
173
+ return "React DevTools hook not available. Ensure the app is running in __DEV__ mode.";
174
+ }
175
+
176
+ const renderers = Array.from(hook.renderers.values());
177
+ if (renderers.length === 0) {
178
+ return "No React renderers found.";
179
+ }
180
+
181
+ // Get fiber roots from the hook
182
+ const fiberRootSet = hook.getFiberRoots?.(renderers[0].rendererID || 1);
183
+ if (!fiberRootSet || fiberRootSet.size === 0) {
184
+ return "No fiber roots found. Is a React Native app running?";
185
+ }
186
+
187
+ function traverseFiber(fiber, depth, maxDepth) {
188
+ if (!fiber || depth > maxDepth) return null;
189
+
190
+ const name = fiber.type?.displayName || fiber.type?.name || fiber.type || 'Unknown';
191
+ const props = {};
192
+
193
+ if (fiber.memoizedProps) {
194
+ for (const [key, value] of Object.entries(fiber.memoizedProps)) {
195
+ if (key === 'children') continue;
196
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
197
+ props[key] = value;
198
+ } else if (typeof value === 'function') {
199
+ props[key] = '[function]';
200
+ }
201
+ }
202
+ }
203
+
204
+ const children = [];
205
+ let child = fiber.child;
206
+ while (child) {
207
+ const result = traverseFiber(child, depth + 1, maxDepth);
208
+ if (result) children.push(result);
209
+ child = child.sibling;
210
+ }
211
+
212
+ // Skip internal React types
213
+ if (typeof name === 'number' || name === 'Unknown') {
214
+ return children.length === 1 ? children[0] : children.length > 0 ? { type: 'Fragment', children } : null;
215
+ }
216
+
217
+ return {
218
+ type: String(name),
219
+ props: Object.keys(props).length > 0 ? props : undefined,
220
+ children: children.length > 0 ? children : undefined,
221
+ };
222
+ }
223
+
224
+ const root = Array.from(fiberRootSet)[0];
225
+ const tree = traverseFiber(root.current, 0, ${maxDepth});
226
+ return JSON.stringify(tree, null, 2);
227
+ } catch (e) {
228
+ return "Error getting component tree: " + e.message;
229
+ }
230
+ })()
231
+ `;
232
+
233
+ const result = await this.cdp.send("Runtime.evaluate", {
234
+ expression: script,
235
+ returnByValue: true,
236
+ });
237
+
238
+ return result?.result?.value || "Could not retrieve component tree";
239
+ }
240
+
241
+ /** Proactively check if Metro bundler is running and healthy */
242
+ async getStatus(): Promise<MetroStatus> {
243
+ const status: MetroStatus = {
244
+ running: false,
245
+ port: this.port,
246
+ packagerStatus: null,
247
+ cdpConnected: this.connected,
248
+ };
249
+
250
+ try {
251
+ const controller = new AbortController();
252
+ const timeout = setTimeout(() => controller.abort(), 3000);
253
+ const response = await fetch(`http://localhost:${this.port}/status`, {
254
+ signal: controller.signal,
255
+ });
256
+ clearTimeout(timeout);
257
+ const body = await response.text();
258
+ status.packagerStatus = body.trim();
259
+ status.running = body.includes("packager-status:running");
260
+ } catch (err: any) {
261
+ status.error = `Cannot reach Metro on port ${this.port}: ${err.message}`;
262
+ }
263
+
264
+ return status;
265
+ }
266
+
267
+ /** Discover the debugger WebSocket endpoint */
268
+ private async discoverEndpoint(): Promise<string> {
269
+ const http = await import("http");
270
+
271
+ try {
272
+ // Query Metro's page list
273
+ const pages = await new Promise<any[]>((resolve, reject) => {
274
+ http
275
+ .get(`http://localhost:${this.port}/json`, (res) => {
276
+ let data = "";
277
+ res.on("data", (chunk) => (data += chunk));
278
+ res.on("end", () => {
279
+ try {
280
+ resolve(JSON.parse(data));
281
+ } catch {
282
+ resolve([]);
283
+ }
284
+ });
285
+ })
286
+ .on("error", reject);
287
+ });
288
+
289
+ // Find the best debugger page
290
+ const debuggerPage = pages.find(
291
+ (p) =>
292
+ p.title?.includes("Hermes") ||
293
+ p.title?.includes("React Native") ||
294
+ p.webSocketDebuggerUrl
295
+ );
296
+
297
+ if (debuggerPage?.webSocketDebuggerUrl) {
298
+ return debuggerPage.webSocketDebuggerUrl;
299
+ }
300
+ } catch {
301
+ // Metro might not support /json endpoint
302
+ }
303
+
304
+ // Fallback to default endpoint
305
+ return `ws://localhost:${this.port}/inspector/device?device=0&page=-1`;
306
+ }
307
+ }